# FeedEntry Decoupling Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the flat `liked_by_viewer`/`boosted_by_viewer` booleans and inline stats fields on `FeedEntry` with two named sub-structs (`EngagementStats`, `Option`), and fix the search adapter to compute real viewer context instead of hardcoding `false`. **Architecture:** Three sequential tasks. Task 1 changes the domain model, which breaks compilation. Task 2 fixes all downstream construction sites and restores compilation. Task 3 adds the functional improvement — viewer-aware SQL in the search adapter. **Tech Stack:** Rust, SQLx, Postgres trigram search (`pg_trgm`). --- ### Task 1: Add `EngagementStats` and `ViewerContext` to the domain model **Files:** - Modify: `crates/domain/src/models/feed.rs` - [ ] **Step 1: Replace the flat fields on `FeedEntry` with two named sub-structs** Replace the entire contents of `crates/domain/src/models/feed.rs` with: ```rust use crate::models::{thought::Thought, user::User}; use crate::value_objects::UserId; #[derive(Debug, Clone)] pub struct EngagementStats { pub like_count: i64, pub boost_count: i64, pub reply_count: i64, } /// Present only when an authenticated viewer made the request. /// `liked`/`boosted` are the viewer's interaction state with this thought. /// `None` means anonymous request or viewer context unavailable. #[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, } #[derive(Debug, Clone)] pub struct UserSummary { pub id: UserId, pub username: String, pub display_name: Option, pub avatar_url: Option, pub bio: Option, pub thought_count: i64, pub follower_count: i64, pub following_count: i64, } #[derive(Debug, Clone)] pub struct PageParams { pub page: u64, pub per_page: u64, } impl PageParams { pub fn offset(&self) -> i64 { ((self.page.saturating_sub(1)) * self.per_page) as i64 } pub fn limit(&self) -> i64 { self.per_page as i64 } } #[derive(Debug, Clone)] pub struct Paginated { pub items: Vec, pub total: i64, pub page: u64, pub per_page: u64, } ``` - [ ] **Step 2: Verify the domain crate compiles (other crates will break)** ```bash cargo check -p domain 2>&1 | head -10 ``` Expected: `domain` compiles clean. Other crates (`postgres`, `postgres-search`, `presentation`) will show errors referencing the removed fields — that is expected and will be fixed in Task 2. - [ ] **Step 3: Commit the domain model change** ```bash git add crates/domain/src/models/feed.rs git commit -m "refactor(domain): FeedEntry — EngagementStats + Option sub-structs" ``` --- ### Task 2: Fix downstream compilation — adapters and handler **Files:** - Modify: `crates/adapters/postgres/src/feed.rs` (line 136 — `row_to_entry`) - Modify: `crates/adapters/postgres-search/src/lib.rs` (line 97 — `row_to_entry`) - Modify: `crates/presentation/src/handlers/feed.rs` (line 22 — `to_thought_response`) - [ ] **Step 1: Update `row_to_entry` in `postgres/src/feed.rs`** Find `row_to_entry` in `crates/adapters/postgres/src/feed.rs` (around line 109). Replace the `Ok(FeedEntry { ... })` block (currently lines 136–144) with: ```rust Ok(FeedEntry { thought, author, stats: domain::models::feed::EngagementStats { like_count: r.like_count, boost_count: r.boost_count, reply_count: r.reply_count, }, viewer: Some(domain::models::feed::ViewerContext { liked: r.liked_by_viewer, boosted: r.boosted_by_viewer, }), }) ``` Note: `postgres/src/feed.rs` already builds `viewer = Some(...)` unconditionally here because its `feed_select(viewer)` function always produces `liked_by_viewer`/`boosted_by_viewer` columns — `false AS liked_by_viewer` when there is no viewer, and the real EXISTS result when there is one. The `Option` distinction (`None` = anonymous) is handled by the caller's knowledge of whether a viewer was passed. To preserve the `None`-when-no-viewer semantic, read how `viewer` is passed into the calling functions and thread it through. Actually, the correct fix: the `row_to_entry` function doesn't know if a viewer was passed. Pass the viewer `Option` as a parameter so it can decide: Replace the signature of `row_to_entry`: ```rust fn row_to_entry(r: FeedRow, viewer: Option) -> Result { ``` And change the construction: ```rust Ok(FeedEntry { thought, author, stats: domain::models::feed::EngagementStats { like_count: r.like_count, boost_count: r.boost_count, reply_count: r.reply_count, }, viewer: viewer.map(|_| domain::models::feed::ViewerContext { liked: r.liked_by_viewer, boosted: r.boosted_by_viewer, }), }) ``` Then update all call sites of `row_to_entry` inside `postgres/src/feed.rs`. Each `FeedRepository` method already has a `viewer` variable of type `Option`. Pass it through: ```rust // Before: .map(row_to_entry) .collect::, _>>()? // After: .map(|r| row_to_entry(r, viewer)) .collect::, _>>()? ``` Read `crates/adapters/postgres/src/feed.rs` to find all five `impl FeedRepository` methods and update each `.map(row_to_entry)` call. - [ ] **Step 2: Update `row_to_entry` in `postgres-search/src/lib.rs`** In `crates/adapters/postgres-search/src/lib.rs`, find `row_to_entry` (line 70). Change the `Ok(FeedEntry { ... })` block (lines 97–105) to: ```rust Ok(FeedEntry { thought, author, stats: domain::models::feed::EngagementStats { like_count: r.like_count, boost_count: r.boost_count, reply_count: r.reply_count, }, viewer: None, // Task 3 will fix this to use real viewer data }) ``` Add `EngagementStats` and `ViewerContext` to the domain import at the top if needed (they're in `domain::models::feed`). The existing import already pulls in `FeedEntry` from that module. - [ ] **Step 3: Update `to_thought_response` in `presentation/src/handlers/feed.rs`** Find `to_thought_response` (line 22 in `crates/presentation/src/handlers/feed.rs`). Update it to read from the new sub-structs: ```rust pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse { ThoughtResponse { id: e.thought.id.as_uuid(), content: e.thought.content.as_str().to_string(), author: to_user_response(&e.author), in_reply_to_id: e.thought.in_reply_to_id.as_ref().map(|id| id.as_uuid()), in_reply_to_url: None, visibility: e.thought.visibility.as_str().to_string(), content_warning: e.thought.content_warning.clone(), sensitive: e.thought.sensitive, like_count: e.stats.like_count, boost_count: e.stats.boost_count, reply_count: e.stats.reply_count, liked_by_viewer: e.viewer.as_ref().map(|v| v.liked).unwrap_or(false), boosted_by_viewer: e.viewer.as_ref().map(|v| v.boosted).unwrap_or(false), created_at: e.thought.created_at, updated_at: e.thought.updated_at, } } ``` `ThoughtResponse` in `api-types/src/responses.rs` keeps `liked_by_viewer: bool` and `boosted_by_viewer: bool` — the wire format is unchanged. - [ ] **Step 4: Compile check — full workspace must be clean** ```bash cargo check --workspace 2>&1 | head -20 ``` Expected: 0 errors. Fix any remaining references to the old flat fields (`e.like_count`, `e.liked_by_viewer`, etc.) — they must become `e.stats.like_count`, `e.viewer.as_ref().map(|v| v.liked).unwrap_or(false)`. - [ ] **Step 5: Commit** ```bash git add crates/adapters/postgres/src/feed.rs \ crates/adapters/postgres-search/src/lib.rs \ crates/presentation/src/handlers/feed.rs git commit -m "refactor(adapters): update FeedEntry construction to use EngagementStats + ViewerContext" ``` --- ### Task 3: Fix search adapter — real viewer context instead of hardcoded `false` **Files:** - Modify: `crates/adapters/postgres-search/src/lib.rs` The `SearchPort::search_thoughts` signature already takes `viewer_id: Option<&UserId>` (the parameter is named `_viewer_id` because it was ignored). This task makes it real. - [ ] **Step 1: Add `liked_by_viewer` and `boosted_by_viewer` to `FeedRow`** In `crates/adapters/postgres-search/src/lib.rs`, find the `FeedRow` struct (line 27). Add two fields at the end: ```rust #[derive(sqlx::FromRow)] struct FeedRow { thought_id: uuid::Uuid, t_user_id: uuid::Uuid, content: String, in_reply_to_id: Option, visibility: String, content_warning: Option, sensitive: bool, t_local: bool, thought_created_at: DateTime, updated_at: Option>, author_id: uuid::Uuid, username: String, email: String, password_hash: String, display_name: Option, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option, author_local: bool, author_created_at: DateTime, author_updated_at: DateTime, like_count: i64, boost_count: i64, reply_count: i64, liked_by_viewer: bool, // NEW boosted_by_viewer: bool, // NEW } ``` - [ ] **Step 2: Replace `FEED_SELECT` constant with a `feed_select(viewer)` function** Delete the `const FEED_SELECT` and replace with a function that injects viewer-aware columns — identical pattern to `postgres/src/feed.rs`: ```rust fn feed_select(viewer: Option) -> String { let viewer_checks = match viewer { Some(uid) => format!( "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" ), None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(), }; format!( " SELECT t.id AS thought_id, t.user_id AS t_user_id, t.content, t.in_reply_to_id, t.visibility, t.content_warning, t.sensitive, t.local AS t_local, t.created_at AS thought_created_at, t.updated_at, u.id AS author_id, u.username, u.email, u.password_hash, u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css, u.local AS author_local, u.created_at AS author_created_at, u.updated_at AS author_updated_at, (SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count, (SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count, (SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count, {viewer_checks} FROM thoughts t JOIN users u ON u.id=t.user_id" ) } ``` - [ ] **Step 3: Update `row_to_entry` to use viewer fields** Update `row_to_entry` to accept `viewer: Option` and build the `ViewerContext`: ```rust fn row_to_entry(r: FeedRow, viewer: Option) -> Result { let thought = Thought { id: ThoughtId::from_uuid(r.thought_id), user_id: UserId::from_uuid(r.t_user_id), content: Content::new_remote(r.content), in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid), visibility: Visibility::from_db_str(&r.visibility)?, content_warning: r.content_warning, sensitive: r.sensitive, local: r.t_local, created_at: r.thought_created_at, updated_at: r.updated_at, }; let author = User { id: UserId::from_uuid(r.author_id), username: Username::from_trusted(r.username), email: Email::from_trusted(r.email), password_hash: PasswordHash(r.password_hash), display_name: r.display_name, bio: r.bio, avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css, local: r.author_local, created_at: r.author_created_at, updated_at: r.author_updated_at, }; Ok(FeedEntry { thought, author, stats: domain::models::feed::EngagementStats { like_count: r.like_count, boost_count: r.boost_count, reply_count: r.reply_count, }, viewer: viewer.map(|_| domain::models::feed::ViewerContext { liked: r.liked_by_viewer, boosted: r.boosted_by_viewer, }), }) } ``` - [ ] **Step 4: Update `search_thoughts` to use viewer_id** Find `search_thoughts` in `crates/adapters/postgres-search/src/lib.rs` (line 110). Rename `_viewer_id` → `viewer_id`, extract the viewer UUID, and thread it through `feed_select` and `row_to_entry`: ```rust async fn search_thoughts( &self, query: &str, page: &PageParams, viewer_id: Option<&UserId>, // was _viewer_id ) -> Result, DomainError> { let viewer = viewer_id.map(|v| v.as_uuid()); let select = feed_select(viewer); let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'", ) .bind(query) .fetch_one(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; let sql = format!( "{select} WHERE t.content % $1 AND t.visibility='public' ORDER BY similarity(t.content, $1) DESC LIMIT $2 OFFSET $3" ); let rows = sqlx::query_as::<_, FeedRow>(&sql) .bind(query) .bind(page.limit()) .bind(page.offset()) .fetch_all(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; Ok(Paginated { items: rows .into_iter() .map(|r| row_to_entry(r, viewer)) .collect::, _>>()?, total, page: page.page, per_page: page.per_page, }) } ``` Note: `USER_SELECT` from `postgres::user` is no longer used in this file after the switch from const to function. Remove the `use postgres::user::{UserRow, USER_SELECT};` import if `UserRow`/`USER_SELECT` are no longer referenced. - [ ] **Step 5: Add an integration test for viewer-aware search** In the `#[cfg(test)]` module in `postgres-search/src/lib.rs`, add after the existing tests: ```rust #[sqlx::test(migrations = "../postgres/migrations")] async fn search_thoughts_sets_viewer_context_when_authed(pool: sqlx::PgPool) { use domain::ports::{LikeRepository, UserWriter}; use postgres::{like::PgLikeRepository, user::PgUserRepository}; use domain::models::social::Like; use domain::value_objects::LikeId; let (alice, thought) = seed_thought(&pool, "alice", "hello world").await; // alice likes her own thought let like_repo = PgLikeRepository::new(pool.clone()); like_repo.save(&Like { id: LikeId::new(), user_id: alice.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: chrono::Utc::now(), }).await.unwrap(); let repo = PgSearchRepository::new(pool); // with viewer — should see liked = true let authed = repo .search_thoughts("hello", &PageParams { page: 1, per_page: 20 }, Some(&alice.id)) .await .unwrap(); assert_eq!(authed.items.len(), 1); let ctx = authed.items[0].viewer.as_ref().expect("viewer context present"); assert!(ctx.liked, "alice should see the thought as liked"); assert!(!ctx.boosted); // without viewer — viewer should be None let anon = repo .search_thoughts("hello", &PageParams { page: 1, per_page: 20 }, None) .await .unwrap(); assert_eq!(anon.items.len(), 1); assert!(anon.items[0].viewer.is_none(), "anonymous request has no viewer context"); } ``` - [ ] **Step 6: Compile check** ```bash cargo check --workspace 2>&1 | head -20 ``` Expected: 0 errors. - [ ] **Step 7: Commit** ```bash git add crates/adapters/postgres-search/src/lib.rs git commit -m "fix(search): viewer-aware SQL in search_thoughts — ViewerContext now real instead of hardcoded false" ``` --- ## Self-Review **Spec coverage:** | Spec requirement | Task | |---|---| | Add `EngagementStats` struct | Task 1 | | Add `ViewerContext` struct | Task 1 | | `FeedEntry.viewer: Option` | Task 1 | | postgres feed adapter uses new structs | Task 2 | | Handler `to_thought_response` uses new fields | Task 2 | | search adapter `viewer: None` (structural fix) | Task 2 | | search adapter uses real viewer SQL (functional fix) | Task 3 | | `viewer: None` = anonymous; `Some(...)` = viewer present | Tasks 2 + 3 | | Wire format (`ThoughtResponse`) unchanged | Task 2 step 3 | **No placeholders found.** **Type consistency:** `EngagementStats` and `ViewerContext` defined in Task 1, used by name in Tasks 2 and 3. `row_to_entry(r, viewer)` signature matches in both Task 2 and Task 3. `viewer: Option` threaded consistently.