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

493 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```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<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)**
```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<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:
```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<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`:
```rust
fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {
```
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<uuid::Uuid>`. Pass it through:
```rust
// 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:
```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<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`:
```rust
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`:
```rust
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_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<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:
```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<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.