feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready #1
492
docs/superpowers/plans/2026-05-15-feedentry-decoupling.md
Normal file
492
docs/superpowers/plans/2026-05-15-feedentry-decoupling.md
Normal file
@@ -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<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 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<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 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<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.
|
||||
Reference in New Issue
Block a user