diff --git a/docs/superpowers/plans/2026-05-15-feedentry-decoupling.md b/docs/superpowers/plans/2026-05-15-feedentry-decoupling.md new file mode 100644 index 0000000..25e539e --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-feedentry-decoupling.md @@ -0,0 +1,492 @@ +# 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.