A community based topic aggregation platform built on atproto
1# Community Feeds Implementation
2
3**Status:** ✅ Implemented (Alpha)
4**PR:** #1 - Community Feed Discovery
5**Date:** October 2025
6
7---
8
9## Problem Statement
10
11### What We're Solving
12
13Users need a way to **browse and discover posts** in communities. Before this implementation:
14
15❌ **No way to see what's in a community**
16- Users could create posts, but couldn't view them
17- No community browsing experience
18- No sorting or ranking algorithms
19- No pagination for large feeds
20
21❌ **Missing core forum functionality**
22- Forums need "Hot", "Top", "New" sorting
23- Users expect Reddit-style ranking
24- Need to discover trending content
25- Must handle thousands of posts per community
26
27### User Stories
28
291. **As a user**, I want to browse /c/gaming and see the hottest posts
302. **As a user**, I want to see top posts from this week in /c/cooking
313. **As a user**, I want to see newest posts in /c/music
324. **As a moderator**, I want posts ranked by engagement to surface quality content
33
34---
35
36## Solution: Hydrated Community Feeds
37
38### Architecture Decision
39
40We chose **hydrated feeds** over Bluesky's skeleton pattern for Alpha:
41
42```
43┌────────────┐
44│ Client │
45└─────┬──────┘
46 │ GET /xrpc/social.coves.feed.getCommunity?community=gaming&sort=hot
47 ▼
48┌─────────────────────┐
49│ Feed Service │ ← Validates request, resolves community DID
50└─────────┬───────────┘
51 ▼
52┌─────────────────────┐
53│ Feed Repository │ ← Single SQL query with JOINs
54│ (PostgreSQL) │ Returns fully hydrated posts
55└─────────┬───────────┘
56 ▼
57 [Full PostViews with author, community, stats]
58```
59
60**Why hydrated instead of skeleton + hydration?**
61
62| Criterion | Hydrated (Our Choice) | Skeleton Pattern |
63|-----------|----------------------|------------------|
64| **Requests** | 1 | 2 (skeleton → hydrate) |
65| **Latency** | Lower | Higher |
66| **Complexity** | Simple | Complex |
67| **Flexibility** | Fixed algorithms | Custom feed generators |
68| **Right for Alpha?** | ✅ Yes | ❌ Overkill |
69| **Future-proof?** | ✅ Can add later | N/A |
70
71**Decision:** Ship fast with hydrated feeds now, add skeleton pattern in Beta when users request custom algorithms.
72
73**Alpha Scope (YAGNI):**
74- ✅ Basic community sorting (hot, top, new)
75- ✅ Public feeds only (no authentication required)
76- ❌ Viewer state (deferred to feed generator phase)
77- ❌ Custom feed algorithms (deferred to Beta)
78
79This keeps Alpha simple and focused on core browsing functionality.
80
81---
82
83## Implementation Details
84
85### 1. Sorting Algorithms
86
87#### **Hot (Reddit Algorithm)**
88
89Balances score and recency for discovery:
90
91```sql
92ORDER BY (score / POWER(age_hours + 2, 1.5)) DESC
93```
94
95**How it works:**
96- New posts with low scores can outrank old posts with high scores
97- Decay factor (1.5) tuned for forum dynamics
98- Posts "age out" naturally over time
99
100**Example:**
101- Post A: 100 upvotes, 1 day old → Rank: 10.4
102- Post B: 10 upvotes, 1 hour old → Rank: 3.5
103- Post C: 50 upvotes, 12 hours old → Rank: 5.1
104
105**Result:** Fresh content surfaces while respecting engagement
106
107#### **Top (Score-Based)**
108
109Pure engagement ranking with timeframe filtering:
110
111```sql
112WHERE created_at > NOW() - INTERVAL '1 day'
113ORDER BY score DESC
114```
115
116**Timeframes:**
117- `hour` - Last 60 minutes
118- `day` - Last 24 hours (default)
119- `week` - Last 7 days
120- `month` - Last 30 days
121- `year` - Last 365 days
122- `all` - All time
123
124#### **New (Chronological)**
125
126Latest first, simple and predictable:
127
128```sql
129ORDER BY created_at DESC
130```
131
132### 2. Pagination
133
134**Keyset pagination** for stability:
135
136```
137Cursor format (base64): "score::created_at::uri"
138Delimiter: :: (following Bluesky convention)
139```
140
141**Why keyset over offset?**
142- ✅ No duplicates when new posts appear
143- ✅ No skipped posts when posts are deleted
144- ✅ Consistent performance at any page depth
145- ✅ Works with all sort orders
146
147**Cursor formats by sort type:**
148- `new`: `timestamp::uri` (e.g., `2025-10-20T12:00:00Z::at://...`)
149- `top`/`hot`: `score::timestamp::uri` (e.g., `100::2025-10-20T12:00:00Z::at://...`)
150
151**Why `::` delimiter?**
152- Doesn't appear in ISO timestamps (which contain single `:`)
153- Doesn't appear in AT-URIs
154- Bluesky convention for cursor pagination
155- Prevents parsing ambiguity
156
157**Example cursor flow:**
158```
159Page 1: No cursor
160 → Returns posts 1-25 + cursor="100::2025-10-20T12:00:00Z::at://..."
161
162Page 2: cursor from page 1
163 → Returns posts 26-50 + cursor="85::2025-10-20T11:30:00Z::at://..."
164
165Page 3: cursor from page 2
166 → Returns posts 51-75 + cursor (or null if end)
167```
168
169### 3. Data Model
170
171#### **FeedViewPost** (Wrapper)
172
173```go
174type FeedViewPost struct {
175 Post *PostView // Full post with all metadata
176 Reason *FeedReason // Why in feed (pin, repost) - Beta
177 Reply *ReplyRef // Reply context - Beta
178}
179```
180
181#### **PostView** (Hydrated Post)
182
183```go
184type PostView struct {
185 URI string // at://did:plc:abc/social.coves.community.post.record/123
186 CID string // Content ID
187 RKey string // Record key (TID)
188 Author *AuthorView // Author with handle, avatar, reputation
189 Community *CommunityRef // Community with name, avatar
190 Title *string // Post title
191 Text *string // Post content
192 TextFacets []interface{} // Rich text (bold, mentions, links)
193 Embed interface{} // Union: images/video/external/quote
194 CreatedAt time.Time // When posted
195 IndexedAt time.Time // When AppView indexed it
196 Stats *PostStats // Upvotes, downvotes, score, comments
197 // Viewer: Not included in Alpha (deferred to feed generator phase)
198}
199```
200
201#### **SQL Query** (Single Query Performance)
202
203```sql
204SELECT
205 p.uri, p.cid, p.rkey,
206 p.author_did, u.handle, u.display_name, u.avatar, -- Author
207 p.community_did, c.name, c.avatar, -- Community
208 p.title, p.content, p.content_facets, p.embed, -- Content
209 p.created_at, p.indexed_at,
210 p.upvote_count, p.downvote_count, p.score, p.comment_count
211FROM posts p
212INNER JOIN users u ON p.author_did = u.did
213INNER JOIN communities c ON p.community_did = c.did
214WHERE p.community_did = $1
215 AND p.deleted_at IS NULL
216 AND (cursor_filter)
217ORDER BY (hot_rank) DESC
218LIMIT 25
219```
220
221**Performance:** One query returns everything - no N+1, no second hydration call.
222
223---
224
225## API Specification
226
227### Endpoint
228
229```
230GET /xrpc/social.coves.feed.getCommunity
231```
232
233### Request Parameters
234
235| Parameter | Type | Required | Default | Description |
236|-----------|------|----------|---------|-------------|
237| `community` | string | ✅ Yes | - | Community DID or handle |
238| `sort` | string | ❌ No | `"hot"` | Sort order: `hot`, `top`, `new` |
239| `timeframe` | string | ❌ No | `"day"` | For `top` sort: `hour`, `day`, `week`, `month`, `year`, `all` |
240| `limit` | integer | ❌ No | `15` | Posts per page (max: 50) |
241| `cursor` | string | ❌ No | - | Pagination cursor from previous response |
242
243### Response
244
245```json
246{
247 "feed": [
248 {
249 "post": {
250 "uri": "at://did:plc:gaming123/social.coves.community.post.record/abc",
251 "cid": "bafyrei...",
252 "author": {
253 "did": "did:plc:alice",
254 "handle": "alice.bsky.social",
255 "displayName": "Alice",
256 "avatar": "https://cdn.bsky.app/avatar/..."
257 },
258 "community": {
259 "did": "did:plc:gaming123",
260 "name": "gaming",
261 "avatar": "https://..."
262 },
263 "title": "Just finished Elden Ring!",
264 "text": "What an incredible journey...",
265 "embed": {
266 "$type": "social.coves.embed.images#view",
267 "images": [
268 {"fullsize": "https://...", "alt": "Final boss screenshot"}
269 ]
270 },
271 "createdAt": "2025-10-20T12:00:00Z",
272 "indexedAt": "2025-10-20T12:00:05Z",
273 "stats": {
274 "upvotes": 42,
275 "downvotes": 3,
276 "score": 39,
277 "commentCount": 15
278 }
279 }
280 }
281 // ... 24 more posts
282 ],
283 "cursor": "Mzk6MjAyNS0xMC0yMFQxMjowMDowMFo6YXQ6Ly8uLi4="
284}
285```
286
287### Example Requests
288
289#### Browse hot posts in /c/gaming
290```bash
291curl 'http://localhost:8081/xrpc/social.coves.feed.getCommunity?community=gaming&sort=hot&limit=25'
292```
293
294#### Top posts this week in /c/cooking
295```bash
296curl 'http://localhost:8081/xrpc/social.coves.feed.getCommunity?community=did:plc:cooking&sort=top&timeframe=week'
297```
298
299#### Page 2 of new posts
300```bash
301curl 'http://localhost:8081/xrpc/social.coves.feed.getCommunity?community=gaming&sort=new&cursor=Mzk6...'
302```
303
304---
305
306## Error Handling
307
308### Error Responses
309
310| Error | Status | When |
311|-------|--------|------|
312| `CommunityNotFound` | 404 | Community doesn't exist |
313| `InvalidRequest` | 400 | Invalid parameters |
314| `InvalidCursor` | 400 | Malformed pagination cursor |
315| `InternalServerError` | 500 | Database or system error |
316
317### Example Error
318
319```json
320{
321 "error": "CommunityNotFound",
322 "message": "Community not found"
323}
324```
325
326---
327
328## Code Structure
329
330### Package Organization
331
332```
333internal/
334├── core/feeds/ # Business logic
335│ ├── interfaces.go # Service & Repository contracts
336│ ├── service.go # Validation, community resolution
337│ ├── types.go # Request/Response models
338│ └── errors.go # Error types
339├── db/postgres/
340│ └── feed_repo.go # SQL queries, sorting algorithms
341└── api/
342 ├── handlers/feed/
343 │ ├── get_community.go # HTTP handler
344 │ └── errors.go # Error mapping
345 └── routes/
346 └── feed.go # Route registration
347```
348
349### Service Layer Flow
350
351```
3521. HandleGetCommunity (HTTP handler)
353 ↓ Parse query params
354
3552. FeedService.GetCommunityFeed
356 ↓ Validate request (sort, limit, timeframe)
357 ↓ Resolve community identifier (handle → DID)
358
3593. FeedRepository.GetCommunityFeed
360 ↓ Build SQL query (ORDER BY based on sort)
361 ↓ Apply timeframe filter (for top)
362 ↓ Apply cursor pagination
363 ↓ Execute single query with JOINs
364 ↓ Scan rows into PostView structs
365 ↓ Build pagination cursor from last post
366
3674. Return FeedResponse
368 ↓ Array of FeedViewPost
369 ↓ Cursor for next page (if more results)
370```
371
372---
373
374## Testing Strategy
375
376### Unit Tests (Future)
377
378- [ ] Feed service validation logic
379- [ ] Cursor encoding/decoding
380- [ ] Sort clause generation
381- [ ] Timeframe filtering
382
383### Integration Tests (Required)
384
385- [x] Test hot/top/new sorting with real posts
386- [x] Test pagination (3 pages, verify no duplicates)
387- [x] Test community resolution (handle → DID)
388- [x] Test error cases (invalid community, bad cursor)
389- [x] Test empty feed (new community)
390- [x] Test limit validation (zero, negative, over max)
391
392### Integration Test Results
393
394**All tests passing ✅**
395
396```bash
397PASS: TestGetCommunityFeed_Hot (0.02s)
398PASS: TestGetCommunityFeed_Top_WithTimeframe (0.02s)
399 PASS: Top_posts_from_last_day (0.00s)
400 PASS: Top_posts_from_all_time (0.00s)
401PASS: TestGetCommunityFeed_New (0.02s)
402PASS: TestGetCommunityFeed_Pagination (0.05s)
403PASS: TestGetCommunityFeed_InvalidCommunity (0.01s)
404PASS: TestGetCommunityFeed_InvalidCursor (0.01s)
405 PASS: Invalid_base64 (0.00s)
406 PASS: Malicious_SQL (0.00s)
407 PASS: Invalid_timestamp (0.00s)
408 PASS: Invalid_URI_format (0.00s)
409PASS: TestGetCommunityFeed_EmptyFeed (0.01s)
410PASS: TestGetCommunityFeed_LimitValidation (0.01s)
411 PASS: Reject_limit_over_50 (0.00s)
412 PASS: Handle_zero_limit_with_default (0.00s)
413
414Total: 8 test cases, 12 sub-tests
415```
416
417**Test Coverage:**
418- ✅ Hot algorithm (score decay over time)
419- ✅ Top algorithm (timeframe filtering: day, all-time)
420- ✅ New algorithm (chronological ordering)
421- ✅ Pagination (3 pages, no duplicates, cursor stability)
422- ✅ Error handling (invalid community, malformed cursors)
423- ✅ Security (cursor injection, SQL injection attempts)
424- ✅ Edge cases (empty feeds, zero/negative limits)
425
426**Location:** `tests/integration/feed_test.go`
427
428---
429
430## Performance Considerations
431
432### Database Indexes
433
434Required indexes for optimal performance:
435
436```sql
437-- Hot sorting (uses score and created_at)
438CREATE INDEX idx_posts_community_hot
439ON posts(community_did, score DESC, created_at DESC)
440WHERE deleted_at IS NULL;
441
442-- Top sorting (score only)
443CREATE INDEX idx_posts_community_top
444ON posts(community_did, score DESC, created_at DESC)
445WHERE deleted_at IS NULL;
446
447-- New sorting (chronological)
448CREATE INDEX idx_posts_community_new
449ON posts(community_did, created_at DESC)
450WHERE deleted_at IS NULL;
451```
452
453### Query Performance
454
455- **Single query** - No N+1 problems
456- **JOINs** - users and communities (always small cardinality)
457- **Pagination** - Keyset, no OFFSET scans
458- **Filtering** - `deleted_at IS NULL` uses partial index
459
460**Expected performance:**
461- 25 posts with full metadata: **< 50ms**
462- 1000+ posts in community: **Still < 50ms** (keyset pagination)
463
464---
465
466## Future Enhancements (Beta)
467
468### 1. Feed Generators (Skeleton Pattern)
469
470Allow users to create custom algorithms:
471
472```
473GET /xrpc/social.coves.feed.getSkeleton?feed=at://alice/feed/best-memes
474 → Returns: [uri1, uri2, uri3, ...]
475
476GET /xrpc/social.coves.community.post.get?uris=[...]
477 → Returns: [full posts]
478```
479
480**Use cases:**
481- User-created feeds ("Best of the week")
482- Algorithmic feeds ("Rising posts", "Controversial")
483- Filtered feeds ("Gaming news only", "No memes")
484
485### 2. Viewer State (Feed Generator Phase)
486
487**Status:** Deferred - Not needed for Alpha's basic community sorting
488
489Include viewer's relationship with posts when implementing feed generators:
490
491```json
492"viewer": {
493 "vote": "up",
494 "voteUri": "at://...",
495 "saved": true,
496 "savedUri": "at://...",
497 "tags": ["read-later", "favorite"]
498}
499```
500
501**Implementation Plan:**
502- Wire up OptionalAuth middleware to feed routes
503- Extract viewer DID from auth context
504- Query viewer state tables (votes, saves, blocks)
505- Include in PostView response
506
507**Requires:**
508- Votes table (user_did, post_uri, vote_type)
509- Saved posts table
510- Blocks table
511- Tags table
512
513**Why deferred:** Alpha only needs raw community sorting (hot/new/top). Viewer-specific features like upvote highlighting and saved posts will be implemented when we build the feed generator skeleton.
514
515### 3. Post Type Filtering (Feed Generator Phase)
516
517**Status:** Deferred - Not needed for Alpha's basic community sorting
518
519Filter by embed type when implementing feed generators:
520
521```
522GET ...?postTypes=image,video
523 → Only image and video posts
524```
525
526**Implementation Plan:**
527- Check `embed->>'$type'` in SQL WHERE clause
528- Map to friendly types (text, image, video, link, quote)
529- Support both single (`postType`) and array (`postTypes`) filtering
530
531**Why deferred:** Alpha displays all posts without filtering. Post type filtering will be useful in feed generators for specialized feeds (e.g., "images only").
532
533### 4. Pinned Posts (Feed Generator Phase)
534
535Moderators pin important posts to top:
536
537```json
538"reason": {
539 "$type": "social.coves.feed.defs#reasonPin",
540 "community": {"did": "...", "name": "gaming"}
541}
542```
543
544### 5. Reply Context
545
546Show post's position in thread:
547
548```json
549"reply": {
550 "root": {"uri": "at://...", "cid": "..."},
551 "parent": {"uri": "at://...", "cid": "..."}
552}
553```
554
555---
556
557## Lexicon Updates
558
559### Updated: `social.coves.community.post.get`
560
561**Changes:**
5621. ✅ Batch URIs: `uri` → `uris[]` (max 25)
5632. ✅ Union embed: Matches Bluesky pattern exactly
5643. ✅ Error handling: `notFoundPost`, `blockedPost`
565
566**Before:**
567```json
568{
569 "parameters": {
570 "uri": "string"
571 },
572 "output": {
573 "post": "#postView"
574 }
575}
576```
577
578**After:**
579```json
580{
581 "parameters": {
582 "uris": ["string"] // Array, max 25
583 },
584 "output": {
585 "posts": [
586 "union": ["#postView", "#notFoundPost", "#blockedPost"]
587 ]
588 }
589}
590```
591
592**Why?**
593- Batch fetching for feed hydration (future)
594- Handle missing/blocked posts gracefully
595- Bluesky compatibility
596
597### Using: `social.coves.feed.getCommunity`
598
599Already exists, matches our implementation:
600
601```json
602{
603 "id": "social.coves.feed.getCommunity",
604 "parameters": {
605 "community": "at-identifier",
606 "sort": "hot|top|new",
607 "timeframe": "hour|day|week|month|year|all",
608 "limit": 1-50,
609 "cursor": "string"
610 },
611 "output": {
612 "feed": ["#feedViewPost"],
613 "cursor": "string"
614 }
615}
616```
617
618---
619
620## Migration Path
621
622### Alpha → Beta: Adding Feed Generators
623
624**Good news:** No breaking changes needed!
625
626**Approach:**
6271. Keep `getCommunity` for standard sorting
6282. Add `getFeedSkeleton` for custom algorithms
6293. Add `post.get` batch support (already lexicon-ready)
6304. Users choose: fast hydrated OR flexible skeleton
631
632**Both coexist:**
633```
634// Standard community browsing (most users)
635GET /xrpc/social.coves.feed.getCommunity?community=gaming&sort=hot
636 → One request, hydrated posts
637
638// Custom feed (power users)
639GET /xrpc/social.coves.feed.getSkeleton?feed=at://alice/feed/best-memes
640 → Returns URIs
641GET /xrpc/social.coves.community.post.get?uris=[...]
642 → Hydrates posts
643```
644
645---
646
647## Success Metrics
648
649### Alpha Launch
650
651- [ ] Users can browse communities
652- [ ] Hot/top/new sorting works correctly
653- [ ] Pagination stable across 3+ pages
654- [ ] Performance < 100ms for 25 posts
655- [ ] Handles 1000+ posts per community
656
657### Future KPIs
658
659- Feed load time (target: < 50ms)
660- Cache hit rate (future: Redis cache)
661- Custom feed adoption (Beta)
662- User engagement (time in feed, clicks)
663
664---
665
666## Dependencies
667
668### Required Services
669
670- ✅ PostgreSQL (AppView database)
671- ✅ Posts indexed via Jetstream
672- ✅ Users indexed via Jetstream
673- ✅ Communities indexed via Jetstream
674
675### Optional (Future)
676
677- Redis (feed caching)
678- Feed generator services (custom algorithms)
679
680---
681
682## Security Considerations
683
684### Input Validation
685
686- ✅ Community identifier format (DID or handle)
687- ✅ Sort parameter (enum: hot/top/new)
688- ✅ Limit (1-50, default 15, explicit rejection over 50)
689- ✅ Cursor (base64 decoding, format validation)
690- ✅ **Cursor injection prevention:**
691 - Timestamp format validation (RFC3339Nano)
692 - URI format validation (must start with `at://`)
693 - Score numeric validation
694 - Part count validation (2 for new, 3 for top/hot)
695
696### SQL Injection Prevention
697
698- ✅ All queries use parameterized statements
699- ✅ **Dynamic ORDER BY uses whitelist map** (defense-in-depth)
700 ```go
701 var sortClauses = map[string]string{
702 "hot": `(p.score / POWER(...)) DESC, p.created_at DESC`,
703 "top": `p.score DESC, p.created_at DESC`,
704 "new": `p.created_at DESC, p.uri DESC`,
705 }
706 ```
707- ✅ **Timeframe filter uses hardcoded switch** (no user input in INTERVAL)
708- ✅ No string concatenation in SQL
709
710### DoS Prevention
711
712- ✅ **Zero-limit pagination fix:** Guards against `limit=0` causing panic
713 - Service layer: Sets default limit if ≤ 0
714 - Repository layer: Additional check before array slicing
715- ✅ Limit validation: Explicit error for limits over 50
716- ✅ Cursor validation: Rejects malformed cursors early
717
718### Rate Limiting
719
720- ✅ Global rate limiter (100 req/min per IP)
721- Future: Per-endpoint limits
722
723### Privacy
724
725- Alpha: All feeds public
726- Beta: Respect community visibility (private/unlisted)
727- Beta: Block lists (hide posts from blocked users)
728
729### Security Audit (PR Review)
730
731All critical and important issues from PR review have been addressed:
732
733**P0 - Critical (Fixed):**
7341. ✅ Zero-limit DoS vulnerability
7352. ✅ Cursor injection attacks
7363. ✅ Validation by-value bug
737
738**Important (Fixed):**
7394. ✅ ORDER BY SQL injection hardening
7405. ✅ Silent error swallowing in JSON encoding
7416. ✅ Limit validation (reject vs silent cap)
742
743**False Positives (Rejected):**
744- ❌ Time filter SQL injection (safe by design)
745- ❌ Nil pointer dereference (impossible condition)
746
747---
748
749## Conclusion
750
751### What We Shipped
752
753✅ **Complete community feed system (Alpha scope)**
754- Hot/top/new sorting algorithms
755- Cursor-based pagination
756- Single-query performance
757- Full post hydration (author, community, stats)
758- Error handling
759- Production-ready code
760- **No viewer state** (YAGNI - deferred to feed generator phase)
761
762### Why It Matters
763
764**Before:** Users could create posts but not see them
765**After:** Full community browsing experience
766
767**Impact:**
768- 🎯 Core forum functionality
769- 🚀 Fast, scalable implementation
770- 🔮 Future-proof architecture
771- 🤝 Bluesky-compatible patterns
772
773### Next Steps
774
7751. ~~**Write E2E tests**~~ ✅ Complete (8 test cases, all passing)
7762. **Performance testing** (1000+ posts under load)
7773. **Add to docs site** (API reference)
7784. **Monitor in production** (query performance, cursor stability)
7795. **PR #2:** Batch `getPosts` for feed generators (Beta)
780
781---
782
783## References
784
785- [PRD: Posts](../PRD_POSTS.md)
786- [Lexicon: getCommunity](../internal/atproto/lexicon/social/coves/feed/getCommunity.json)
787- [Lexicon: post.get](../internal/atproto/lexicon/social/coves/post/get.json)
788- [Bluesky Feed Pattern](https://github.com/bluesky-social/atproto/discussions/4245)
789- [Reddit Hot Algorithm](https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9)
790
791---
792
793**Document Version:** 1.0
794**Last Updated:** October 20, 2025
795**Status:** ✅ Implemented, Ready for Testing