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

17 KiB
Raw Blame History

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<ViewerContext>), 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:

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<ViewerContext>,
}

#[derive(Debug, Clone)]
pub struct UserSummary {
    pub id: UserId,
    pub username: String,
    pub display_name: Option<String>,
    pub avatar_url: Option<String>,
    pub bio: Option<String>,
    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<T> {
    pub items: Vec<T>,
    pub total: i64,
    pub page: u64,
    pub per_page: u64,
}
  • Step 2: Verify the domain crate compiles (other crates will break)
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
git add crates/domain/src/models/feed.rs
git commit -m "refactor(domain): FeedEntry — EngagementStats + Option<ViewerContext> 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 136144) with:

    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<ViewerContext> 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<uuid::Uuid> as a parameter so it can decide:

Replace the signature of row_to_entry:

fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {

And change the construction:

    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<uuid::Uuid>. Pass it through:

// Before:
.map(row_to_entry)
.collect::<Result<Vec<_>, _>>()?

// After:
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?

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 97105) to:

    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:

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
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
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:

#[derive(sqlx::FromRow)]
struct FeedRow {
    thought_id: uuid::Uuid,
    t_user_id: uuid::Uuid,
    content: String,
    in_reply_to_id: Option<uuid::Uuid>,
    visibility: String,
    content_warning: Option<String>,
    sensitive: bool,
    t_local: bool,
    thought_created_at: DateTime<Utc>,
    updated_at: Option<DateTime<Utc>>,
    author_id: uuid::Uuid,
    username: String,
    email: String,
    password_hash: String,
    display_name: Option<String>,
    bio: Option<String>,
    avatar_url: Option<String>,
    header_url: Option<String>,
    custom_css: Option<String>,
    author_local: bool,
    author_created_at: DateTime<Utc>,
    author_updated_at: DateTime<Utc>,
    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:

fn feed_select(viewer: Option<uuid::Uuid>) -> 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<uuid::Uuid> and build the ViewerContext:

fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {
    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_idviewer_id, extract the viewer UUID, and thread it through feed_select and row_to_entry:

async fn search_thoughts(
    &self,
    query: &str,
    page: &PageParams,
    viewer_id: Option<&UserId>,          // was _viewer_id
) -> Result<Paginated<FeedEntry>, 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::<Result<Vec<_>, _>>()?,
        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:

#[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
cargo check --workspace 2>&1 | head -20

Expected: 0 errors.

  • Step 7: Commit
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<ViewerContext> 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<uuid::Uuid> threaded consistently.