17 KiB
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
FeedEntrywith 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_entryinpostgres/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:
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_entryinpostgres-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:
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_responseinpresentation/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_viewerandboosted_by_viewertoFeedRow
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_SELECTconstant with afeed_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_entryto 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_thoughtsto 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:
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.