Comment System Implementation#
Overview#
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.
Implementation Date: November 4-6, 2025 Status: ✅ Phase 1, 2A, 2B & 2C Complete - Production-Ready with Full Metadata Hydration Test Coverage:
- 35 integration tests (18 indexing + 11 query + 6 voting)
- 22 unit tests (32 scenarios, 94.3% code coverage)
- All tests passing ✅ Last Updated: November 6, 2025 (Phase 2C complete - user/community/record metadata)
Development Phases#
This implementation follows a phased approach for maintainability and proper scoping:
✅ Phase 1: Indexing Infrastructure (Current - COMPLETE)#
What was built:
- Jetstream consumer for indexing comment CREATE/UPDATE/DELETE events
- PostgreSQL schema with proper indexes and denormalized counts
- Repository layer with comprehensive query methods
- Atomic parent count updates (posts.comment_count, comments.reply_count)
- Out-of-order event handling with reconciliation
- Soft delete support preserving thread structure
- Full integration test coverage (20 tests)
What works:
- Comments are indexed from Jetstream firehose as users create them
- Threading relationships tracked (root + parent references)
- Parent counts automatically maintained
- Comment updates and deletes processed correctly
- Out-of-order events reconciled automatically
What's NOT in this phase:
- ❌ No HTTP API endpoints for querying comments
- ❌ No service layer (repository is sufficient for indexing)
- ❌ No rate limiting or auth middleware
- ❌ No API documentation
✅ Phase 2A: Query API - COMPLETE (November 5, 2025)#
What was built:
- Lexicon definitions:
social.coves.community.comment.defsandgetComments - Database query methods with Lemmy hot ranking algorithm
- Service layer with iterative loading strategy for nested replies
- XRPC HTTP handler with optional DPoP authentication
- Comprehensive integration test suite (11 test scenarios)
What works:
- Fetch comments on any post with sorting (hot/top/new)
- Nested replies up to configurable depth (default 10, max 100)
- Lemmy hot ranking:
log(greatest(2, score + 2)) / power(time_decay, 1.8) - Cursor-based pagination for stable scrolling
- Optional DPoP authentication for viewer state (stubbed for Phase 2B)
- Timeframe filtering for "top" sort (hour/day/week/month/year/all)
Endpoints:
GET /xrpc/social.coves.community.comment.getComments- Required:
post(AT-URI) - Optional:
sort(hot/top/new),depth(0-100),limit(1-100),cursor,timeframe - Returns: Array of
threadViewCommentwith nested replies + post context - Supports DPoP-bound access token for authenticated requests (viewer state)
- Required:
Files created (9):
internal/atproto/lexicon/social/coves/community/comment/defs.json- View definitionsinternal/atproto/lexicon/social/coves/community/comment/getComments.json- Query endpointinternal/core/comments/comment_service.go- Business logic layerinternal/core/comments/view_models.go- API response typesinternal/api/handlers/comments/get_comments.go- HTTP handlerinternal/api/handlers/comments/errors.go- Error handling utilitiesinternal/api/handlers/comments/middleware.go- Auth middlewareinternal/api/handlers/comments/service_adapter.go- Service layer adaptertests/integration/comment_query_test.go- Integration tests
Files modified (7):
internal/db/postgres/comment_repo.go- Added query methods (~450 lines), fixed INNER→LEFT JOIN, fixed window function SQLinternal/core/comments/interfaces.go- Added service interfaceinternal/core/comments/comment.go- Added CommenterHandle fieldinternal/core/comments/errors.go- Added IsValidationError helpercmd/server/main.go- Wired up routes and service with all repositoriestests/integration/comment_query_test.go- Updated test helpers for new service signaturedocs/COMMENT_SYSTEM_IMPLEMENTATION.md- This document
Total new code: ~2,400 lines
Test coverage:
- 11 integration test scenarios covering:
- Basic fetch, nested replies, depth limits
- Hot/top/new sorting algorithms
- Pagination with cursor stability
- Empty threads, deleted comments
- Invalid input handling
- HTTP handler end-to-end
- Repository layer tested (hot ranking formula, pagination)
- Service layer tested (threading, depth limits)
- Handler tested (input validation, error cases)
- All tests passing ✅
🔒 Production Hardening (PR Review Fixes - November 5, 2025)#
After initial implementation, a thorough PR review identified several critical issues that were addressed before production deployment:
Critical Issues Fixed#
1. N+1 Query Problem (99.7% reduction in queries)
- Problem: Nested reply loading made separate DB queries for each comment's children
- Impact: Could execute 1,551 queries for a post with 50 comments at depth 3
- Solution: Implemented batch loading with PostgreSQL window functions
- Added
ListByParentsBatch()method usingROW_NUMBER() OVER (PARTITION BY parent_uri) - Refactored
buildThreadViews()to collect parent URIs per level and fetch in one query - Result: Reduced from 1,551 queries → 4 queries (1 per depth level)
- Added
- Files:
internal/core/comments/interfaces.go,internal/db/postgres/comment_repo.go,internal/core/comments/comment_service.go
2. Post Not Found Returns 500 Instead of 404
- Problem: When fetching comments for non-existent post, service returned wrapped
posts.ErrNotFoundwhich handler didn't recognize - Impact: Clients got HTTP 500 instead of proper HTTP 404
- Solution: Added error translation in service layer
if posts.IsNotFound(err) { return nil, ErrRootNotFound // Recognized by comments.IsNotFound() } - File:
internal/core/comments/comment_service.go:68-72
Important Issues Fixed#
3. Missing Endpoint-Specific Rate Limiting
- Problem: Comment queries with deep nesting expensive but only protected by global 100 req/min limit
- Solution: Added dedicated rate limiter at 20 req/min for comment endpoint
- File:
cmd/server/main.go:429-439
4. Unbounded Cursor Size (DoS Vector)
- Problem: No validation before base64 decoding - attacker could send massive cursor string
- Solution: Added 1024-byte max size check before decoding
- File:
internal/db/postgres/comment_repo.go:547-551
5. Missing Query Timeout
- Problem: Deep nested queries could run indefinitely
- Solution: Added 10-second context timeout to
GetComments() - File:
internal/core/comments/comment_service.go:62-64
6. Post View Not Populated (P0 Blocker)
- Problem: Lexicon marked
postfield as required but response always returnednull - Impact: Violated schema contract, would break client deserialization
- Solution:
- Updated service to accept
posts.Repositoryinstead ofinterface{} - Added
buildPostView()method to construct post views with author/community/stats - Fetch post before returning response
- Updated service to accept
- Files:
internal/core/comments/comment_service.go:33-36,:66-73,:224-274
7. Missing Record Fields (P0 Blocker)
- Problem: Both
postView.recordandcommentView.recordfields were null despite lexicon marking them as required - Impact: Violated lexicon contract, would break strict client deserialization
- Solution:
- Added
buildPostRecord()method to construct minimal PostRecord from Post entity - Added
buildCommentRecord()method to construct minimal CommentRecord from Comment entity - Both methods populate required fields (type, reply refs, content, timestamps)
- Added TODOs for Phase 2C to unmarshal JSON fields (embed, facets, labels)
- Added
- Files:
internal/core/comments/comment_service.go:260-288,:366-386
8. Handle/Name Format Violations (P0 & Important)
- Problem:
postView.author.handlecontained DID instead of proper handle (violatesformat:"handle")postView.community.namecontained DID instead of community name
- Impact: Lexicon format constraints violated, poor UX showing DIDs instead of readable names
- Solution:
- Added
users.UserRepositoryto service for author handle hydration - Added
communities.Repositoryto service for community name hydration - Updated
buildPostView()to fetch user and community records with DID fallback - Log warnings for missing records but don't fail entire request
- Added
- Files:
internal/core/comments/comment_service.go:34-37,:292-325,cmd/server/main.go:297
9. Data Loss from INNER JOIN (P1 Critical)
- Problem: Three query methods used
INNER JOIN userswhich 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)
- Solution:
- Changed
INNER JOIN users→LEFT JOIN usersin all three methods - Added
COALESCE(u.handle, c.commenter_did)to gracefully fall back to DID - Preserves all comments while still hydrating handles when available
- Changed
- Files:
internal/db/postgres/comment_repo.go:396,:407,:415,:694-706,:761-836
10. Window Function SQL Bug (P0 Critical)
- Problem:
ListByParentsBatchusedORDER BY hot_rank DESCin 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
- Solution:
- Created separate
windowOrderByvariable that inlines full hot_rank formula - PostgreSQL evaluates window ORDER BY before SELECT, so must use full expression
- Hot sort now works correctly with nested replies
- Created separate
- Files:
internal/db/postgres/comment_repo.go:776,:808 - Critical Note: This affected default sorting mode (hot) and would have broken production UX
Documentation Added#
11. Hot Rank Caching Strategy
- Documented when and how to implement cached hot rank column
- Specified observability metrics to monitor (p95 latency, CPU usage)
- Documented trade-offs between cached vs on-demand computation
Test Coverage:
- All fixes verified with existing integration test suite
- Added test cases for error handling scenarios
- All integration tests passing (comment_query_test.go: 11 tests)
Rationale for phased approach:
- Separation of concerns: Indexing and querying are distinct responsibilities
- Testability: Phase 1 can be fully tested without API layer
- Incremental delivery: Indexing can run in production while API is developed
- Scope management: Prevents feature creep and allows focused code review
Hot Ranking Algorithm (Lemmy-Based)#
Formula#
log(greatest(2, score + 2)) /
power(((EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600) + 2), 1.8)
Explanation#
Components:
-
greatest(2, score + 2): Ensures log input never goes below 2- Prevents negative log values for heavily downvoted comments
- Score of -5 → log(2), same as score of 0
- Prevents brigading from creating "anti-viral" comments
-
power(..., 1.8): Time decay exponent- Higher than posts (1.5) for faster comment aging
- Comments should be fresher than posts
-
+ 2offsets: Prevent divide-by-zero for very new comments
Behavior:
- High score + old = lower rank (content ages naturally)
- Low score + new = higher rank (fresh content gets visibility)
- Negative scores don't break the formula (bounded at log(2))
Sort Modes#
Hot (default):
ORDER BY hot_rank DESC, score DESC, created_at DESC
Top (with timeframe):
WHERE created_at >= NOW() - INTERVAL '1 day'
ORDER BY score DESC, created_at DESC
New (chronological):
ORDER BY created_at DESC
Path-Based Ordering#
Comments are ordered within their tree level:
ORDER BY
path ASC, -- Maintains parent-child structure
hot_rank DESC, -- Sorts siblings by rank
score DESC, -- Tiebreaker
created_at DESC -- Final tiebreaker
Result: Siblings compete with siblings, but children never outrank their parent.
Architecture#
Data Flow#
Client → User's PDS → Jetstream Firehose → Comment Consumer → PostgreSQL AppView
↓
Atomic updates to parent counts
(posts.comment_count OR comments.reply_count)
Key Design Principles#
- User-Owned Records: Comments live in user repositories (like votes), not community repositories (like posts)
- atProto Native: Uses
com.atproto.repo.createRecord/updateRecord/deleteRecord - Threading via Strong References: Root + parent system allows unlimited nesting depth
- Out-of-Order Indexing: No foreign key constraints to allow Jetstream events to arrive in any order
- Idempotent Operations: Safe for Jetstream replays and duplicate events
- Atomic Count Updates: Database transactions ensure consistency
- Soft Deletes: Preserves thread structure when comments are deleted
Implementation Details#
1. Lexicon Definition#
Location: internal/atproto/lexicon/social/coves/feed/comment.json
The lexicon was already defined and follows atProto best practices:
{
"lexicon": 1,
"id": "social.coves.community.comment",
"defs": {
"main": {
"type": "record",
"key": "tid",
"required": ["reply", "content", "createdAt"],
"properties": {
"reply": {
"type": "ref",
"ref": "#replyRef",
"description": "Reference to the post and parent being replied to"
},
"content": {
"type": "string",
"maxGraphemes": 3000,
"maxLength": 30000
},
"facets": { /* Rich text annotations */ },
"embed": { /* Images, quoted posts */ },
"langs": { /* ISO 639-1 language codes */ },
"labels": { /* Self-applied content labels */ },
"createdAt": { /* RFC3339 timestamp */ }
}
},
"replyRef": {
"required": ["root", "parent"],
"properties": {
"root": {
"type": "ref",
"ref": "com.atproto.repo.strongRef",
"description": "Strong reference to the original post"
},
"parent": {
"type": "ref",
"ref": "com.atproto.repo.strongRef",
"description": "Strong reference to immediate parent (post or comment)"
}
}
}
}
}
Threading Model:
root: Always points to the original post that started the threadparent: Points to the immediate parent (can be a post or another comment)- This enables unlimited nested threading while maintaining the root reference
2. Database Schema#
Migration: internal/db/migrations/016_create_comments_table.sql
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
uri TEXT UNIQUE NOT NULL, -- AT-URI (at://commenter_did/social.coves.community.comment/rkey)
cid TEXT NOT NULL, -- Content ID
rkey TEXT NOT NULL, -- Record key (TID)
commenter_did TEXT NOT NULL, -- User who commented (from AT-URI repo field)
-- Threading structure (reply references)
root_uri TEXT NOT NULL, -- Strong reference to original post
root_cid TEXT NOT NULL, -- CID of root post (version pinning)
parent_uri TEXT NOT NULL, -- Strong reference to immediate parent
parent_cid TEXT NOT NULL, -- CID of parent (version pinning)
-- Content
content TEXT NOT NULL, -- Comment text (max 3000 graphemes, 30000 bytes)
content_facets JSONB, -- Rich text facets
embed JSONB, -- Embedded content (images, quoted posts)
content_labels JSONB, -- Self-applied labels (com.atproto.label.defs#selfLabels)
langs TEXT[], -- Languages (ISO 639-1, max 3)
-- Timestamps
created_at TIMESTAMPTZ NOT NULL, -- Commenter's timestamp from record
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ, -- Soft delete
-- Stats (denormalized for performance)
upvote_count INT NOT NULL DEFAULT 0, -- Comments CAN be voted on
downvote_count INT NOT NULL DEFAULT 0,
score INT NOT NULL DEFAULT 0, -- upvote_count - downvote_count
reply_count INT NOT NULL DEFAULT 0 -- Number of direct replies
);
Key Indexes:
-- Threading queries (most important for UX)
CREATE INDEX idx_comments_root ON comments(root_uri, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX idx_comments_parent ON comments(parent_uri, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX idx_comments_parent_score ON comments(parent_uri, score DESC, created_at DESC)
WHERE deleted_at IS NULL;
-- User queries
CREATE INDEX idx_comments_commenter ON comments(commenter_did, created_at DESC);
-- Vote targeting
CREATE INDEX idx_comments_uri_active ON comments(uri)
WHERE deleted_at IS NULL;
Design Decisions:
- No FK on
commenter_did: Allows out-of-order Jetstream indexing (comment events may arrive before user events) - Soft delete pattern:
deleted_at IS NULLin indexes for performance - Vote counts included: The vote lexicon explicitly allows voting on comments (not just posts)
- StrongRef with CID: Version pinning prevents confusion when parent content changes
3. Domain Layer#
Comment Entity#
File: internal/core/comments/comment.go
type Comment struct {
ID int64
URI string
CID string
RKey string
CommenterDID string
// Threading
RootURI string
RootCID string
ParentURI string
ParentCID string
// Content
Content string
ContentFacets *string
Embed *string
ContentLabels *string
Langs []string
// Timestamps
CreatedAt time.Time
IndexedAt time.Time
DeletedAt *time.Time
// Stats
UpvoteCount int
DownvoteCount int
Score int
ReplyCount int
}
Repository Interface#
File: internal/core/comments/interfaces.go
type Repository interface {
Create(ctx context.Context, comment *Comment) error
Update(ctx context.Context, comment *Comment) error
GetByURI(ctx context.Context, uri string) (*Comment, error)
Delete(ctx context.Context, uri string) error
// Threading queries
ListByRoot(ctx context.Context, rootURI string, limit, offset int) ([]*Comment, error)
ListByParent(ctx context.Context, parentURI string, limit, offset int) ([]*Comment, error)
CountByParent(ctx context.Context, parentURI string) (int, error)
// User queries
ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error)
}
Error Types#
File: internal/core/comments/errors.go
Standard error types following the vote system pattern, with helper functions IsNotFound() and IsConflict().
4. Repository Implementation#
File: internal/db/postgres/comment_repo.go
Idempotent Create Pattern#
func (r *postgresCommentRepo) Create(ctx context.Context, comment *Comment) error {
query := `
INSERT INTO comments (...)
VALUES (...)
ON CONFLICT (uri) DO NOTHING
RETURNING id, indexed_at
`
err := r.db.QueryRowContext(ctx, query, ...).Scan(&comment.ID, &comment.IndexedAt)
// ON CONFLICT DO NOTHING returns no rows if duplicate
if err == sql.ErrNoRows {
return nil // Already exists - OK for Jetstream replays
}
return err
}
Update Preserving Vote Counts#
func (r *postgresCommentRepo) Update(ctx context.Context, comment *Comment) error {
query := `
UPDATE comments
SET cid = $1, content = $2, content_facets = $3,
embed = $4, content_labels = $5, langs = $6
WHERE uri = $7 AND deleted_at IS NULL
RETURNING id, indexed_at, created_at,
upvote_count, downvote_count, score, reply_count
`
// Vote counts and created_at are preserved (not in SET clause)
err := r.db.QueryRowContext(ctx, query, ...).Scan(...)
return err
}
Soft Delete#
func (r *postgresCommentRepo) Delete(ctx context.Context, uri string) error {
query := `
UPDATE comments
SET deleted_at = NOW()
WHERE uri = $1 AND deleted_at IS NULL
`
result, err := r.db.ExecContext(ctx, query, uri)
// Idempotent: Returns success even if already deleted
return err
}
5. Jetstream Consumer#
File: internal/atproto/jetstream/comment_consumer.go
Event Handler#
func (c *CommentEventConsumer) HandleEvent(ctx context.Context, event *JetstreamEvent) error {
if event.Kind != "commit" || event.Commit == nil {
return nil
}
if event.Commit.Collection == "social.coves.community.comment" {
switch event.Commit.Operation {
case "create":
return c.createComment(ctx, event.Did, commit)
case "update":
return c.updateComment(ctx, event.Did, commit)
case "delete":
return c.deleteComment(ctx, event.Did, commit)
}
}
return nil
}
Atomic Count Updates#
func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx, comment *Comment) error {
tx, _ := c.db.BeginTx(ctx, nil)
defer tx.Rollback()
// 1. Insert comment (idempotent)
err = tx.QueryRowContext(ctx, `
INSERT INTO comments (...) VALUES (...)
ON CONFLICT (uri) DO NOTHING
RETURNING id
`).Scan(&commentID)
if err == sql.ErrNoRows {
tx.Commit()
return nil // Already indexed
}
// 2. Update parent counts atomically
// Try posts table first
tx.ExecContext(ctx, `
UPDATE posts
SET comment_count = comment_count + 1
WHERE uri = $1 AND deleted_at IS NULL
`, comment.ParentURI)
// If no post updated, parent is probably a comment
tx.ExecContext(ctx, `
UPDATE comments
SET reply_count = reply_count + 1
WHERE uri = $1 AND deleted_at IS NULL
`, comment.ParentURI)
return tx.Commit()
}
Security Validation#
func (c *CommentEventConsumer) validateCommentEvent(ctx, repoDID string, comment *CommentRecord) error {
// Comments MUST come from user repositories (repo owner = commenter DID)
if !strings.HasPrefix(repoDID, "did:") {
return fmt.Errorf("invalid commenter DID format: %s", repoDID)
}
// Content is required
if comment.Content == "" {
return fmt.Errorf("comment content is required")
}
// Reply references must have both URI and CID
if comment.Reply.Root.URI == "" || comment.Reply.Root.CID == "" {
return fmt.Errorf("invalid root reference: must have both URI and CID")
}
if comment.Reply.Parent.URI == "" || comment.Reply.Parent.CID == "" {
return fmt.Errorf("invalid parent reference: must have both URI and CID")
}
return nil
}
Security Note: We do NOT verify that the user exists in the AppView because:
- Comment events may arrive before user events in Jetstream (race condition)
- The comment came from the user's PDS repository (authenticated by PDS)
- No database FK constraint allows out-of-order indexing
- Orphaned comments (from never-indexed users) are harmless
6. WebSocket Connector#
File: internal/atproto/jetstream/comment_jetstream_connector.go
Follows the standard Jetstream connector pattern with:
- Auto-reconnect on errors (5-second retry)
- Ping/pong keepalive (30-second ping, 60-second read deadline)
- Graceful shutdown via context cancellation
- Subscribes to:
wantedCollections=social.coves.community.comment
7. Server Integration#
File: cmd/server/main.go (lines 289-396)
// Initialize comment repository
commentRepo := postgresRepo.NewCommentRepository(db)
log.Println("✅ Comment repository initialized (Jetstream indexing only)")
// Start Jetstream consumer for comments
commentJetstreamURL := os.Getenv("COMMENT_JETSTREAM_URL")
if commentJetstreamURL == "" {
commentJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.community.comment"
}
commentEventConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
commentJetstreamConnector := jetstream.NewCommentJetstreamConnector(commentEventConsumer, commentJetstreamURL)
go func() {
if startErr := commentJetstreamConnector.Start(ctx); startErr != nil {
log.Printf("Comment Jetstream consumer stopped: %v", startErr)
}
}()
log.Printf("Started Jetstream comment consumer: %s", commentJetstreamURL)
log.Println(" - Indexing: social.coves.community.comment CREATE/UPDATE/DELETE operations")
log.Println(" - Updating: Post comment counts and comment reply counts atomically")
Testing#
Test Suite#
File: tests/integration/comment_consumer_test.go
Test Coverage: 6 test suites, 18 test cases, 100% passing
1. TestCommentConsumer_CreateComment#
- ✅ Create comment on post
- ✅ Verify comment is indexed correctly
- ✅ Verify post comment count is incremented
- ✅ Idempotent create - duplicate events don't double-count
2. TestCommentConsumer_Threading#
- ✅ Create first-level comment (reply to post)
- ✅ Create second-level comment (reply to comment)
- ✅ Verify both comments have same root (original post)
- ✅ Verify parent relationships are correct
- ✅ Verify reply counts are updated
- ✅ Query all comments by root (flat list)
- ✅ Query direct replies to post
- ✅ Query direct replies to comment
3. TestCommentConsumer_UpdateComment#
- ✅ Create comment with initial content
- ✅ Manually set vote counts to simulate votes
- ✅ Update comment content
- ✅ Verify content is updated
- ✅ Verify CID is updated
- ✅ Verify vote counts are preserved
- ✅ Verify created_at is preserved
4. TestCommentConsumer_DeleteComment#
- ✅ Create comment
- ✅ Delete comment (soft delete)
- ✅ Verify deleted_at is set
- ✅ Verify post comment count is decremented
- ✅ Idempotent delete - duplicate deletes don't double-decrement
5. TestCommentConsumer_SecurityValidation#
- ✅ Reject comment with empty content
- ✅ Reject comment with invalid root reference (missing URI)
- ✅ Reject comment with invalid parent reference (missing CID)
- ✅ Reject comment with invalid DID format
6. TestCommentRepository_Queries#
- ✅ ListByRoot returns all comments in thread (4 comments)
- ✅ ListByParent returns direct replies to post (2 comments)
- ✅ ListByParent returns direct replies to comment (2 comments)
- ✅ CountByParent returns correct counts
- ✅ ListByCommenter returns all user's comments
Test Results#
=== Test Summary ===
PASS: TestCommentConsumer_CreateComment (0.02s)
PASS: TestCommentConsumer_Threading (0.02s)
PASS: TestCommentConsumer_UpdateComment (0.02s)
PASS: TestCommentConsumer_DeleteComment (0.02s)
PASS: TestCommentConsumer_SecurityValidation (0.01s)
PASS: TestCommentRepository_Queries (0.02s)
✅ ALL 18 TESTS PASS
Total time: 0.115s
Key Features#
✅ Comments ARE Votable#
The vote lexicon explicitly states: "Record declaring a vote (upvote or downvote) on a post or comment"
Comments include full vote tracking:
upvote_countdownvote_countscore(calculated as upvote_count - downvote_count)
✅ Comments ARE Editable#
Unlike votes (which are immutable), comments support UPDATE operations:
- Content, facets, embed, and labels can be updated
- Vote counts and created_at are preserved
- CID is updated to reflect new version
✅ Threading Support#
Unlimited nesting depth via root + parent system:
- Every comment knows its root post
- Every comment knows its immediate parent
- Easy to query entire threads or direct replies
- Soft deletes preserve thread structure
✅ Out-of-Order Indexing#
No foreign key constraints allow events to arrive in any order:
- Comment events may arrive before user events
- Comment events may arrive before post events
- All operations are idempotent
- Safe for Jetstream replays
✅ Atomic Consistency#
Database transactions ensure counts are always accurate:
- Comment creation increments parent count
- Comment deletion decrements parent count
- No race conditions
- No orphaned counts
Implementation Statistics#
Phase 1 - Indexing Infrastructure#
Files Created: 8
internal/db/migrations/016_create_comments_table.sql- 60 linesinternal/core/comments/comment.go- 80 linesinternal/core/comments/interfaces.go- 45 linesinternal/core/comments/errors.go- 40 linesinternal/db/postgres/comment_repo.go- 340 linesinternal/atproto/jetstream/comment_consumer.go- 530 linesinternal/atproto/jetstream/comment_jetstream_connector.go- 130 linestests/integration/comment_consumer_test.go- 930 lines
Files Modified: 1
cmd/server/main.go- Added 20 lines for Jetstream consumer
Phase 1 Total: ~2,175 lines
Phase 2A - Query API#
Files Created: 9 (listed above in Phase 2A section)
Files Modified: 6 (listed above in Phase 2A section)
Phase 2A Total: ~2,400 lines
Combined Total: ~4,575 lines#
Reference Pattern: Vote System#
The comment implementation closely follows the vote system pattern:
| Aspect | Votes | Comments |
|---|---|---|
| Location | User repositories | User repositories |
| Lexicon | social.coves.feed.vote |
social.coves.community.comment |
| Operations | CREATE, DELETE | CREATE, UPDATE, DELETE |
| Mutability | Immutable | Editable |
| Foreign Keys | None (out-of-order indexing) | None (out-of-order indexing) |
| Delete Pattern | Soft delete | Soft delete |
| Idempotency | ON CONFLICT DO NOTHING | ON CONFLICT DO NOTHING |
| Count Updates | Atomic transaction | Atomic transaction |
| Security | PDS authentication | PDS authentication |
Future Phases#
✅ Phase 2B: Vote Integration - COMPLETE (November 6, 2025)#
What was built:
- URI parsing utility (
ExtractCollectionFromURI) for routing votes to correct table - Vote consumer refactored to support comment votes via URI collection parsing
- Comment consumer refactored with same URI parsing pattern (consistency + performance)
- Viewer vote state integration in comment service with batch loading
- Comprehensive integration tests (6 test scenarios)
What works:
- Users can upvote/downvote comments (same as posts)
- Vote counts (upvote_count, downvote_count, score) atomically updated on comments
- Viewer vote state populated in comment queries (viewer.vote, viewer.voteUri)
- URI parsing routes votes 1,000-20,000x faster than "try both tables" pattern
- Batch loading prevents N+1 queries for vote state (one query per depth level)
Files modified (6):
internal/atproto/utils/record_utils.go- Added ExtractCollectionFromURI utilityinternal/atproto/jetstream/vote_consumer.go- Refactored for comment support with URI parsinginternal/atproto/jetstream/comment_consumer.go- Applied URI parsing pattern for consistencyinternal/core/comments/comment_service.go- Integrated vote state with batch loadingtests/integration/comment_vote_test.go- New test file (~560 lines)docs/COMMENT_SYSTEM_IMPLEMENTATION.md- Updated status
Test coverage:
- 6 integration test scenarios covering:
- Vote creation (upvote/downvote) with count updates
- Vote deletion with count decrements
- Viewer state population (authenticated with vote, authenticated without vote, unauthenticated)
- All tests passing ✅
Performance improvements:
- URI parsing vs database queries: 1,000-20,000x faster
- One query per table instead of two (worst case eliminated)
- Consistent pattern across both consumers
Actual time: 5-7 hours (including URI parsing refactor for both consumers)
🔒 Phase 2B Production Hardening (PR Review Fixes - November 6, 2025)#
After Phase 2B implementation, a thorough PR review identified several critical issues and improvements that were addressed before production deployment:
Critical Issues Fixed#
1. Post Comment Count Reconciliation (P0 Data Integrity)
- Problem: When a comment arrives before its parent post (common with Jetstream's cross-repository event ordering), the post update returns 0 rows affected. Later when the post is indexed, there was NO reconciliation logic to count pre-existing comments, causing posts to have permanently stale
comment_countvalues. - Impact: Posts would show incorrect comment counts indefinitely, breaking UX and violating data integrity
- Solution: Implemented reconciliation in post consumer (similar to existing pattern in comment consumer)
- Added
indexPostAndReconcileCounts()method that runs within transaction - After inserting post with
ON CONFLICT DO NOTHING, queries for pre-existing comments - Updates
comment_countatomically:SET comment_count = (SELECT COUNT(*) FROM comments WHERE parent_uri = $1) - All operations happen within same transaction as post insert
- Added
- Files:
internal/atproto/jetstream/post_consumer.go(~95 lines added) - Updated: 6 files total (main.go + 5 test files with new constructor signature)
2. Error Wrapping in Logging (Non-Issue - Review Mistake)
- Initial Request: Change
log.Printf("...%v", err)tolog.Printf("...%w", err)in vote consumer - Investigation:
%wonly works infmt.Errorf(), notlog.Printf() - Conclusion: Original code was correct -
%vis proper format verb for logging - Outcome: No changes needed; error is properly returned on next line to preserve error chain
3. Incomplete Comment Record Construction (Deferred to Phase 2C)
- Issue: Rich text facets, embeds, and labels are stored in database but not deserialized in API responses
- Decision: Per original Phase 2C plan, defer JSON field deserialization (already marked with TODO comments)
- Rationale: Phase 2C explicitly covers "complete record" population - no scope creep needed
Important Issues Fixed#
4. Nil Pointer Handling in Vote State (Code Safety)
- Problem: Taking address of type-asserted variables directly from type assertion could be risky during refactoring
if direction, hasDirection := voteMap["direction"].(string); hasDirection { viewer.Vote = &direction // ❌ Takes address of type-asserted variable } - Impact: Potential pointer bugs if code is refactored or patterns are reused
- Solution: Create explicit copies before taking addresses
if direction, hasDirection := voteMap["direction"].(string); hasDirection { directionCopy := direction viewer.Vote = &directionCopy // ✅ Takes address of explicit copy } - File:
internal/core/comments/comment_service.go:277-291
5. Unit Test Coverage (Testing Gap)
- Problem: Only integration tests existed - no unit tests with mocks for service layer
- Impact: Slower test execution, harder to test edge cases in isolation
- Solution: Created comprehensive unit test suite
- New file:
internal/core/comments/comment_service_test.go(~1,130 lines) - 22 test functions with 32 total scenarios
- Manual mocks for all repository interfaces (4 repos)
- Tests for GetComments(), buildThreadViews(), buildCommentView(), validation
- Coverage: 94.3% of comment service code
- Execution: ~10ms (no database, pure unit tests)
- New file:
- Test Scenarios:
- Happy paths with/without viewer authentication
- Error handling (post not found, repository errors)
- Edge cases (empty results, deleted comments, nil pointers)
- Sorting options (hot/top/new/invalid)
- Input validation (bounds enforcement, defaults)
- Vote state hydration with batch loading
- Nested threading logic with depth limits
6. ExtractCollectionFromURI Input Validation (Documentation Gap)
- Problem: Function returned empty string for malformed URIs with no clear indication in documentation
- Impact: Unclear to callers what empty string means (error? missing data?)
- Solution: Enhanced documentation with explicit semantics
- Documented that empty string means "unknown/unsupported collection"
- Added guidance for callers to validate return value before use
- Provided examples of valid and invalid inputs
- File:
internal/atproto/utils/record_utils.go:19-36
7. Race Conditions in Test Data (Flaky Tests)
- Problem: Tests used
time.Now()which could lead to timing-sensitive failures - Impact: Tests could be flaky if database query takes >1 second or system clock changes
- Solution: Replaced all
time.Now()calls with fixed timestampsfixedTime := time.Date(2025, 11, 6, 12, 0, 0, 0, time.UTC) - File:
tests/integration/comment_vote_test.go(9 replacements) - Benefit: Tests are now deterministic and repeatable
8. Viewer Authentication Validation (Non-Issue - Architecture Working as Designed)
- Initial Concern: ViewerDID field trusted without verification in service layer
- Investigation: Authentication IS properly validated at middleware layer
OptionalAuthmiddleware extracts and validates DPoP-bound access tokens- Uses PDS public keys (JWKS) for signature verification
- Validates DPoP proof, token expiration, DID format, issuer
- Only injects verified DIDs into request context
- Handler extracts DID using
middleware.GetUserDID(r)
- Architecture: Follows industry best practices (authentication at perimeter)
- Outcome: Code is secure; added documentation comments explaining the security boundary
- Recommendation: Added clear comments in service explaining authentication contract
Optimizations Implemented#
9. Batch Vote Query Optimization (Performance)
- Problem: Query selected unused columns (
cid,created_at) that weren't accessed by service - Solution: Optimized to only select needed columns
- Before:
SELECT subject_uri, direction, uri, cid, created_at - After:
SELECT subject_uri, direction, uri
- Before:
- File:
internal/db/postgres/comment_repo.go:895-899 - Benefit: Reduced query overhead and memory usage
10. Magic Numbers Made Visible (Maintainability)
- Problem:
repliesPerParent = 5was inline constant in function - Solution: Promoted to package-level constant with documentation
const ( // DefaultRepliesPerParent defines how many nested replies to load per parent comment // This balances UX (showing enough context) with performance (limiting query size) // Can be made configurable via constructor if needed in the future DefaultRepliesPerParent = 5 ) - File:
internal/core/comments/comment_service.go - Benefit: Better visibility, easier to find/modify, documents intent
Test Coverage Summary#
Integration Tests (35 tests):
- 18 indexing tests (comment_consumer_test.go)
- 11 query API tests (comment_query_test.go)
- 6 voting tests (comment_vote_test.go)
- All passing ✅
Unit Tests (22 tests, NEW):
- 8 GetComments tests (valid request, errors, viewer states, sorting)
- 4 buildThreadViews tests (empty input, deleted comments, nested replies, depth limit)
- 5 buildCommentView tests (basic fields, top-level, nested, viewer votes)
- 5 validation tests (nil request, defaults, bounds, invalid values)
- Code Coverage: 94.3% of comment service
- All passing ✅
Files Modified (9 total)#
Core Implementation:
internal/atproto/jetstream/post_consumer.go- Post reconciliation (~95 lines)internal/core/comments/comment_service.go- Nil pointer fixes, constantinternal/atproto/utils/record_utils.go- Enhanced documentationinternal/db/postgres/comment_repo.go- Query optimizationtests/integration/comment_vote_test.go- Fixed timestamps- NEW:
internal/core/comments/comment_service_test.go- Unit tests (~1,130 lines)
Test Updates:
7. cmd/server/main.go - Updated post consumer constructor
8. tests/integration/post_e2e_test.go - 5 constructor updates
9. tests/integration/aggregator_e2e_test.go - 1 constructor update
Production Readiness Checklist#
✅ Data Integrity: Post comment count reconciliation prevents stale counts ✅ Code Safety: Nil pointer handling fixed, no undefined behavior ✅ Test Coverage: 94.3% unit test coverage + comprehensive integration tests ✅ Documentation: Clear comments on authentication, error handling, edge cases ✅ Performance: Optimized queries, batch loading, URI parsing ✅ Security: Authentication validated at middleware, documented architecture ✅ Maintainability: Constants documented, magic numbers eliminated ✅ Reliability: Fixed timestamp tests prevent flakiness
Total Implementation Effort: Phase 2B initial (5-7 hours) + PR hardening (6-8 hours) = ~11-15 hours
📋 Phase 2C: Post/User/Community Integration (✅ Complete - November 6, 2025)#
Implementation Summary: Phase 2C completes the comment query API by adding full metadata hydration for authors, communities, and comment records including rich text support.
Completed Features:
- ✅ Integrated post repository in comment service
- ✅ Return postView in getComments response with all fields
- ✅ Populate post author DID, community DID, stats (upvotes, downvotes, score, comment count)
- ✅ Batch user loading - Added
GetByDIDs()repository method for efficient N+1 prevention - ✅ User handle hydration - Authors display correct handles from users table
- ✅ Community metadata - Community name and avatar URL properly populated
- ✅ Rich text facets - Deserialized from JSONB for mentions, links, formatting
- ✅ Embeds - Deserialized from JSONB for images and quoted posts
- ✅ Content labels - Deserialized from JSONB for NSFW/spoiler warnings
- ✅ Complete record field - Full verbatim atProto record with all nested fields
Implementation Details:
1. Batch User Loading (Performance Optimization)#
Files Modified: internal/db/postgres/user_repo.go, internal/core/users/interfaces.go
Added batch loading pattern to prevent N+1 queries when hydrating comment authors:
// New repository method
GetByDIDs(ctx context.Context, dids []string) (map[string]*User, error)
// Implementation uses PostgreSQL ANY() with pq.Array for efficiency
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))
Performance Impact:
- Before: N+1 queries (1 query per comment author)
- After: 1 batch query for all authors in thread
- ~10-100x faster for threads with many unique authors
2. Community Name and Avatar Hydration#
Files Modified: internal/core/comments/comment_service.go
Enhanced buildPostView() to fetch and populate full community metadata:
// Community name selection priority
1. DisplayName (user-friendly: "Gaming Community")
2. Name (short name: "gaming")
3. Handle (canonical: "gaming.community.coves.social")
4. DID (fallback)
// Avatar URL construction
Format: {pds_url}/xrpc/com.atproto.sync.getBlob?did={community_did}&cid={avatar_cid}
Example: https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did:plc:abc123&cid=bafyreiabc123
Lexicon Compliance: Matches social.coves.community.post.get#communityRef
3. Rich Text and Embed Deserialization#
Files Modified: internal/core/comments/comment_service.go
Properly deserializes JSONB fields from database into structured view models:
Content Facets (Rich Text Annotations):
- Mentions:
{"$type": "social.coves.richtext.facet#mention", "did": "..."} - Links:
{"$type": "social.coves.richtext.facet#link", "uri": "https://..."} - Formatting:
{"$type": "social.coves.richtext.facet#bold|italic|strikethrough"} - Spoilers:
{"$type": "social.coves.richtext.facet#spoiler", "reason": "..."}
Embeds (Attached Content):
- Images:
social.coves.embed.images- Up to 8 images with alt text and aspect ratios - Quoted Posts:
social.coves.embed.post- Strong reference to another post
Content Labels (Self-Applied Warnings):
- NSFW, graphic media, spoilers per
com.atproto.label.defs#selfLabels
Error Handling:
- All parsing errors logged as warnings
- Requests succeed even if rich content fails to parse
- Graceful degradation maintains API reliability
Implementation:
// Deserialize facets
var contentFacets []interface{}
if comment.ContentFacets != nil && *comment.ContentFacets != "" {
if err := json.Unmarshal([]byte(*comment.ContentFacets), &contentFacets); err != nil {
log.Printf("Warning: Failed to unmarshal content facets: %v", err)
}
}
// Same pattern for embeds and labels
Test Coverage:
- All existing integration tests pass with Phase 2C changes
- Batch user loading verified in
TestCommentVote_ViewerState - No SQL warnings or errors in test output
Dependencies:
- Phase 2A query API (✅ Complete)
- Phase 2B voting and viewer state (✅ Complete)
- Post repository integration (✅ Complete)
- User repository integration (✅ Complete)
- Community repository integration (✅ Complete)
Actual Implementation Effort: ~2 hours (3 subagents working in parallel)
📋 Phase 3: Advanced Features (Future)#
3A: Distinguished Comments#
- Moderator/admin comment flags
- Priority sorting for distinguished comments
- Visual indicators in UI
3B: Comment Search & Filtering#
- Full-text search within threads
- Filter by author, time range, score
- Search across community comments
3C: Moderation Tools#
- Hide/remove comments
- Flag system for user reports
- Moderator queue
- Audit log
3D: Notifications#
- Notify users of replies to their comments
- Notify post authors of new comments
- Mention notifications (@user)
- Customizable notification preferences
3E: Enhanced Features#
- Comment edit history tracking
- Save/bookmark comments
- Sort by "controversial" (high engagement, low score)
- Collapsible comment threads
- User-specific comment history API
- Community-wide comment stats/analytics
✅ Phase 4: Namespace Migration (COMPLETED)#
Completed: 2025-11-16
Scope:
- ✅ Migrated
social.coves.community.commentnamespace tosocial.coves.community.comment - ✅ Updated lexicon definitions (record and query schemas)
- ✅ Updated Jetstream consumer collection filter
- ✅ Updated all code references (consumer, service, validation layers)
- ✅ Updated integration tests and test data generation scripts
- ✅ Created database migration (018_migrate_comment_namespace.sql)
Note: Since we're pre-production, no historical data migration was needed. Migration script updates URIs in comments table (uri, root_uri, parent_uri columns).
Performance Considerations#
Database Indexes#
All critical query patterns are indexed:
- Threading queries:
idx_comments_root,idx_comments_parent - Sorting by score:
idx_comments_parent_score - User history:
idx_comments_commenter - Vote targeting:
idx_comments_uri_active
Denormalized Counts#
Vote counts and reply counts are denormalized for performance:
- Avoids
COUNT(*)queries on large datasets - Updated atomically with comment operations
- Indexed for fast sorting
Pagination Support#
All list queries support limit/offset pagination:
ListByRoot(ctx, rootURI, limit, offset)ListByParent(ctx, parentURI, limit, offset)ListByCommenter(ctx, commenterDID, limit, offset)
N+1 Query Prevention#
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.
Solution Implemented: Batch loading strategy using window functions:
- Collect all parent URIs at each depth level
- Execute single batch query using
ListByParentsBatch()with PostgreSQL window functions - Group results by parent URI in memory
- Recursively process next level
Performance Improvement:
- Old: 1 + N + (N × M) + (N × M × P) queries per request
- New: 1 query per depth level (max 4 queries for depth 3)
- Example with depth 3, 50 comments: 1,551 queries → 4 queries (99.7% reduction)
Implementation Details:
-- Uses ROW_NUMBER() window function to limit per parent efficiently
WITH ranked_comments AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY parent_uri
ORDER BY hot_rank DESC
) as rn
FROM comments
WHERE parent_uri = ANY($1)
)
SELECT * FROM ranked_comments WHERE rn <= $2
Hot Rank Caching Strategy#
Current Implementation: Hot rank is computed on-demand for every query using the Lemmy algorithm:
log(greatest(2, score + 2)) /
power(((EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600) + 2), 1.8)
Performance Impact:
- Computed for every comment in every hot-sorted query
- PostgreSQL handles this efficiently for moderate loads (<1000 comments per post)
- No noticeable performance degradation in testing
Future Optimization (if needed):
If hot rank computation becomes a bottleneck at scale:
- Add cached column:
ALTER TABLE comments ADD COLUMN hot_rank_cached NUMERIC;
CREATE INDEX idx_comments_parent_hot_rank_cached
ON comments(parent_uri, hot_rank_cached DESC)
WHERE deleted_at IS NULL;
- Background recomputation job:
// Run every 5-15 minutes
func (j *HotRankJob) UpdateHotRanks(ctx context.Context) error {
query := `
UPDATE comments
SET hot_rank_cached = log(greatest(2, score + 2)) /
power(((EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600) + 2), 1.8)
WHERE deleted_at IS NULL
`
_, err := j.db.ExecContext(ctx, query)
return err
}
- Use cached value in queries:
SELECT * FROM comments
WHERE parent_uri = $1
ORDER BY hot_rank_cached DESC, score DESC
When to implement:
- Monitor query performance in production
- If p95 query latency > 200ms for hot-sorted queries
- If database CPU usage from hot rank computation > 20%
- Only optimize if measurements show actual bottleneck
Trade-offs:
- Cached approach: Faster queries, but ranks update every 5-15 minutes (slightly stale)
- On-demand approach: Always fresh ranks, slightly higher query cost
- For comment discussions, 5-15 minute staleness is acceptable (comments age slowly)
Conclusion#
The comment system has successfully completed Phase 1 (Indexing), Phase 2A (Query API), Phase 2B (Vote Integration), and Phase 2C (Metadata Hydration) with comprehensive production hardening, providing a production-ready threaded discussion system for Coves:
✅ Phase 1 Complete: Full indexing infrastructure with Jetstream consumer ✅ Phase 2A Complete: Query API with hot ranking, threading, and pagination ✅ Phase 2B Complete: Vote integration with viewer state and URI parsing optimization ✅ Phase 2C Complete: Full metadata hydration (users, communities, rich text) ✅ Production Hardened: Two rounds of PR review fixes (Phase 2A + Phase 2B) ✅ Fully Tested:
- 35 integration tests (indexing, query, voting)
- 22 unit tests (94.3% coverage)
- All tests passing ✅ ✅ Secure:
- Authentication validated at middleware layer
- Input validation, parameterized queries
- Security documentation added ✅ Scalable:
- N+1 query prevention with batch loading (99.7% reduction for replies, 10-100x for users)
- URI parsing optimization (1,000-20,000x faster than DB queries)
- Indexed queries, denormalized counts, cursor pagination ✅ Data Integrity:
- Post comment count reconciliation
- Atomic count updates
- Out-of-order event handling ✅ atProto Native: User-owned records, Jetstream indexing, Bluesky patterns ✅ Rich Content: Facets, embeds, labels properly deserialized and populated
Key Features Implemented:
- Threaded comments with unlimited nesting
- Hot/top/new sorting with Lemmy algorithm
- Upvote/downvote on comments with atomic count updates
- Viewer vote state in authenticated queries
- Batch loading for nested replies, vote state, and user metadata
- Out-of-order Jetstream event handling with reconciliation
- Soft deletes preserving thread structure
- Full author metadata (handles from users table)
- Community metadata (names, avatars)
- Rich text facets (mentions, links, formatting)
- Embedded content (images, quoted posts)
- Content labels (NSFW, spoilers)
Code Quality:
- 94.3% unit test coverage on service layer
- Comprehensive integration test suite
- Production hardening from two PR review cycles
- Clear documentation and inline comments
- Consistent patterns across codebase
Next milestones:
- Phase 3: Advanced features (moderation, notifications, search, edit history)
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.
Appendix: Command Reference#
Run Tests#
Phase 1 - Indexing Tests:
TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \
go test -v ./tests/integration/comment_consumer_test.go \
./tests/integration/user_test.go \
./tests/integration/helpers.go \
-run "TestCommentConsumer" -timeout 60s
Phase 2A - Query API Tests:
TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \
go test -v ./tests/integration/comment_query_test.go \
./tests/integration/user_test.go \
./tests/integration/helpers.go \
-run "TestCommentQuery" -timeout 120s
Phase 2B - Voting Tests:
TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \
go test -v ./tests/integration/ \
-run "TestCommentVote" -timeout 60s
Unit Tests (Service Layer):
# Run all unit tests
go test -v ./internal/core/comments/... -short
# Run with coverage report
go test -cover ./internal/core/comments/...
# Generate HTML coverage report
go test -coverprofile=coverage.out ./internal/core/comments/...
go tool cover -html=coverage.out
# Run specific test category
go test -v ./internal/core/comments/... -run TestCommentService_GetComments
go test -v ./internal/core/comments/... -run TestCommentService_buildThreadViews
go test -v ./internal/core/comments/... -run TestValidateGetCommentsRequest
All Comment Tests (Integration + Unit):
# Integration tests (requires database)
TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \
go test -v ./tests/integration/comment_*.go \
./tests/integration/user_test.go \
./tests/integration/helpers.go \
-timeout 120s
# Unit tests (no database)
go test -v ./internal/core/comments/... -short
Apply Migration#
GOOSE_DRIVER=postgres \
GOOSE_DBSTRING="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \
goose -dir internal/db/migrations up
Build Server#
go build ./cmd/server
Environment Variables#
# Jetstream URL (optional, defaults to localhost:6008)
export COMMENT_JETSTREAM_URL="ws://localhost:6008/subscribe?wantedCollections=social.coves.community.comment"
# Database URL
export TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable"
Last Updated: November 6, 2025 Status: ✅ Phase 1, 2A, 2B & 2C Complete - Production-Ready with Full Metadata Hydration Documentation: Comprehensive implementation guide covering all phases, PR reviews, and production considerations