Files
thoughts/docs/superpowers/specs/2026-05-15-feedentry-decoupling-design.md

3.1 KiB

FeedEntry Decoupling Design

Goal: Fix search viewer context (functional), restructure FeedEntry for clarity (structural), and make viewer presence explicit via Option<ViewerContext> (type-safe).

Priority: C (search fix) → B (struct clarity) → A (type safety). All three land in one pass.


Data Model

Replace flat fields on FeedEntry with two named sub-structs in crates/domain/src/models/feed.rs:

#[derive(Debug, Clone)]
pub struct EngagementStats {
    pub like_count:  i64,
    pub boost_count: i64,
    pub reply_count: i64,
}

#[derive(Debug, Clone)]
pub struct ViewerContext {
    pub liked:   bool,
    pub boosted: bool,
}

#[derive(Debug, Clone)]
pub struct FeedEntry {
    pub thought: Thought,
    pub author:  User,
    pub stats:   EngagementStats,
    pub viewer:  Option<ViewerContext>,  // None when no authenticated viewer
}

viewer: None means the request was anonymous or viewer state is unavailable (e.g. search without auth). viewer: Some(ViewerContext { liked: false, boosted: false }) means a viewer is known and they have not liked or boosted the thought. These two states are now distinct at the type level.


Search Adapter Fix

SearchPort::search_thoughts already accepts viewer_id: Option<&UserId> but postgres-search/src/lib.rs ignores it, always hardcoding false for viewer fields.

Fix: conditionally inject EXISTS subqueries into the search SQL, identical to the pattern used in postgres/src/feed.rs:

-- viewer_id = None (anonymous)
false AS liked_by_viewer,
false AS boosted_by_viewer

-- viewer_id = Some(uid)
EXISTS(SELECT 1 FROM likes  WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,
EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer

The FeedRow struct in postgres-search already has liked_by_viewer: bool and boosted_by_viewer: bool columns — they just need to be populated correctly. No schema change required.


Callsite Migration

File Change
crates/domain/src/models/feed.rs Replace flat stats/viewer fields with EngagementStats and Option<ViewerContext>
crates/adapters/postgres/src/feed.rsrow_to_entry Construct EngagementStats { ... } and viewer: Some/None based on FeedRow
crates/adapters/postgres-search/src/lib.rsrow_to_entry + SQL Fix SQL to use viewer_id; build Option<ViewerContext> from result
crates/presentation/src/handlers/feed.rsto_thought_response e.stats.like_count, `e.viewer.as_ref().map(
crates/domain/src/testing.rsTestStore feed impl Build FeedEntry with stats: and viewer: fields

ThoughtResponse in api-types/src/responses.rs keeps liked_by_viewer: bool and boosted_by_viewer: bool — the wire format is unchanged. viewer: None serialises as false in to_thought_response.


What Does Not Change

  • FeedRepository port signatures (still returns Paginated<FeedEntry>)
  • HTTP response shape (ThoughtResponse)
  • Database schema
  • Pagination, filtering, or query logic
  • Any code path that doesn't touch FeedEntry fields directly