+25
cmd/server/main.go
+25
cmd/server/main.go
······+commentJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.comment"+commentJetstreamConnector := jetstream.NewCommentJetstreamConnector(commentEventConsumer, commentJetstreamURL)
+54
docs/PRD_BACKLOG.md
+54
docs/PRD_BACKLOG.md
···+When comments arrive before their parent post is indexed (common with cross-repo Jetstream ordering), the post's `comment_count` is never reconciled. Later, when the post consumer indexes the post, there's no logic to count pre-existing comments. This causes posts to have permanently stale `comment_count` values.+- Comment consumer updates post counts when processing comment events ([comment_consumer.go:323-343](../internal/atproto/jetstream/comment_consumer.go#L323-L343))+- When post consumer later indexes the post, it sets `comment_count = 0` with NO reconciliation+Post consumer MUST implement the same reconciliation pattern as comment consumer (see [comment_consumer.go:292-305](../internal/atproto/jetstream/comment_consumer.go#L292-L305)):+- Post indexing from Jetstream ([post_consumer.go](../internal/atproto/jetstream/post_consumer.go))+- 🔴 Issue documented with FIXME(P1) comment at [comment_consumer.go:311-321](../internal/atproto/jetstream/comment_consumer.go#L311-L321)+- ⚠️ Test demonstrating limitation exists: `TestCommentConsumer_PostCountReconciliation_Limitation`+- `tests/integration/post_consumer_test.go` - Add test for out-of-order comment reconciliation
+665
internal/atproto/jetstream/comment_consumer.go
+665
internal/atproto/jetstream/comment_consumer.go
···+func (c *CommentEventConsumer) createComment(ctx context.Context, repoDID string, commit *CommitEvent) error {+func (c *CommentEventConsumer) updateComment(ctx context.Context, repoDID string, commit *CommitEvent) error {+log.Printf("Warning: Update event for non-existent comment: %s (will be indexed on CREATE)", uri)+log.Printf("🚨 SECURITY: Rejecting comment update - threading references are immutable: %s", uri)+log.Printf(" Incoming root: %s (CID: %s)", commentRecord.Reply.Root.URI, commentRecord.Reply.Root.CID)+log.Printf(" Existing parent: %s (CID: %s)", existingComment.ParentURI, existingComment.ParentCID)+log.Printf(" Incoming parent: %s (CID: %s)", commentRecord.Reply.Parent.URI, commentRecord.Reply.Parent.CID)+func (c *CommentEventConsumer) deleteComment(ctx context.Context, repoDID string, commit *CommitEvent) error {+func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, comment *comments.Comment) error {+checkErr := tx.QueryRowContext(ctx, checkQuery, comment.URI).Scan(&existingID, &existingDeletedAt)+comment.Content, comment.ContentFacets, comment.Embed, comment.ContentLabels, pq.Array(comment.Langs),+log.Printf("Warning: Parent not found or deleted: %s (comment indexed anyway)", comment.ParentURI)+func (c *CommentEventConsumer) deleteCommentAndUpdateCounts(ctx context.Context, comment *comments.Comment) error {+log.Printf("Warning: Parent not found or deleted: %s (comment deleted anyway)", comment.ParentURI)+func (c *CommentEventConsumer) validateCommentEvent(ctx context.Context, repoDID string, comment *CommentRecordFromJetstream) error {+return fmt.Errorf("comment content exceeds maximum length (%d bytes): got %d bytes", MaxCommentContentBytes, len(comment.Content))+// serializeOptionalFields serializes facets, embed, and labels from a comment record to JSON strings+func serializeOptionalFields(commentRecord *CommentRecordFromJetstream) (facetsJSON, embedJSON, labelsJSON *string) {
+125
internal/atproto/jetstream/comment_jetstream_connector.go
+125
internal/atproto/jetstream/comment_jetstream_connector.go
···+func NewCommentJetstreamConnector(consumer *CommentEventConsumer, wsURL string) *CommentJetstreamConnector {+if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
+80
internal/core/comments/comment.go
+80
internal/core/comments/comment.go
···
+44
internal/core/comments/errors.go
+44
internal/core/comments/errors.go
···
+45
internal/core/comments/interfaces.go
+45
internal/core/comments/interfaces.go
···+ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error)
+63
internal/db/migrations/016_create_comments_table.sql
+63
internal/db/migrations/016_create_comments_table.sql
···+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;+COMMENT ON TABLE comments IS 'Comments indexed from user repositories via Jetstream firehose consumer';+COMMENT ON COLUMN comments.uri IS 'AT-URI in format: at://commenter_did/social.coves.feed.comment/rkey';+COMMENT ON COLUMN comments.root_uri IS 'Strong reference to the original post that started the thread';+COMMENT ON COLUMN comments.parent_uri IS 'Strong reference to immediate parent (post or comment)';+COMMENT ON COLUMN comments.score IS 'Computed as upvote_count - downvote_count for ranking replies';+COMMENT ON COLUMN comments.content_labels IS 'Self-applied labels per com.atproto.label.defs#selfLabels (JSONB: {"values":[{"val":"nsfw","neg":false}]})';
+354
internal/db/postgres/comment_repo.go
+354
internal/db/postgres/comment_repo.go
···+comment.Content, comment.ContentFacets, comment.Embed, comment.ContentLabels, pq.Array(comment.Langs),+func (r *postgresCommentRepo) GetByURI(ctx context.Context, uri string) (*comments.Comment, error) {+func (r *postgresCommentRepo) ListByRoot(ctx context.Context, rootURI string, limit, offset int) ([]*comments.Comment, error) {+func (r *postgresCommentRepo) ListByParent(ctx context.Context, parentURI string, limit, offset int) ([]*comments.Comment, error) {+func (r *postgresCommentRepo) CountByParent(ctx context.Context, parentURI string) (int, error) {+func (r *postgresCommentRepo) ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*comments.Comment, error) {
+1763
tests/integration/comment_consumer_test.go
+1763
tests/integration/comment_consumer_test.go
···+err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", testPostURI).Scan(&commentCount)+err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", testPostURI).Scan(&initialCount)+err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", testPostURI).Scan(&finalCount)+t.Errorf("Comment count should not increase on duplicate event. Initial: %d, Final: %d", initialCount, finalCount)+testPostURI := createTestPost(t, db, testCommunity, testUser.DID, "Threading Test", 0, time.Now())+comment1URI := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", testUser.DID, comment1Rkey)+comment2URI := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", testUser.DID, comment2Rkey)+testPostURI := createTestPost(t, db, testCommunity, testUser.DID, "Delete Test", 0, time.Now())+err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", testPostURI).Scan(&initialCount)+err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", testPostURI).Scan(&finalCount)+t.Errorf("Expected comment count to decrease by 1. Initial: %d, Final: %d", initialCount, finalCount)+err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", testPostURI).Scan(&countAfterFirstDelete)+err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", testPostURI).Scan(&countAfterSecondDelete)+t.Errorf("Count should not change on duplicate delete. After first: %d, After second: %d", countAfterFirstDelete, countAfterSecondDelete)+testPostURI := createTestPost(t, db, testCommunity, testUser.DID, "Security Test", 0, time.Now())+postURI := createTestPost(t, db, testCommunity, testUser.DID, "OOO Test Post", 0, time.Now())+t.Errorf("Expected parent reply_count to be 1 (reconciled), got %d", parentComment.ReplyCount)+t.Errorf("Expected parent reply_count to be 3 (reconciled), got %d", parentComment.ReplyCount)+postURI := createTestPost(t, db, testCommunity, testUser.DID, "Resurrection Test", 0, time.Now())+err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", postURI).Scan(&postCommentCount)+err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", post1URI).Scan(&post1Count)+err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", post1URI).Scan(&post1Count)+t.Errorf("Expected parent URI to be %s (Post 2), got %s (STALE!)", post2URI, comment.ParentURI)+err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", post2URI).Scan(&post2Count)+err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", post1URI).Scan(&post1Count)+// TestCommentConsumer_ThreadingImmutability tests that UPDATE events cannot change threading refs