1231 lines
44 KiB
Markdown
1231 lines
44 KiB
Markdown
# Domain & Application Refactor 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:** Clean up 11 architectural problems in `crates/domain/` and `crates/application/` — from most critical to least — applying CQRS port splits, removing AP infrastructure leakage from the domain, making invalid states unrepresentable, and eliminating pass-through noise.
|
|
|
|
**Architecture:** Hexagonal (ports & adapters). Domain knows nothing about HTTP, SQL, or ActivityPub. Application orchestrates domain via ports. Adapters implement ports. CQRS: every repository is split into a read-side trait and a write-side trait; the concrete adapter implements both; a combined supertrait keeps `AppState` unchanged.
|
|
|
|
**Tech Stack:** Rust, Tokio, SQLx, Axum. All changes must pass `cargo check --workspace` and the pre-commit hook (`cargo fmt && cargo clippy`).
|
|
|
|
---
|
|
|
|
## Phase 1 — Remove AP Infrastructure from the Domain (most critical)
|
|
|
|
These four tasks are the most invasive. They must land together in one commit because they form a closed loop of changes.
|
|
|
|
### Task 1: Add AP lookup accessors to `ActivityPubRepository`
|
|
|
|
The domain models `User` and `Thought` currently carry AP-specific fields (`ap_id`, `inbox_url`, `in_reply_to_url`). Before we can remove those fields, the services that currently read them need another way to get the data. This task adds that other way.
|
|
|
|
**Files:**
|
|
- Modify: `crates/domain/src/ports.rs`
|
|
- Modify: `crates/adapters/postgres/src/activitypub.rs`
|
|
- Modify: `crates/domain/src/testing.rs`
|
|
|
|
- [ ] **Step 1: Add `ActorApUrls` struct and two new methods to `ActivityPubRepository` in `ports.rs`**
|
|
|
|
In `crates/domain/src/ports.rs`, after the `OutboxEntry` struct (~line 336), add:
|
|
|
|
```rust
|
|
/// AP-protocol endpoints for a locally-stored user (local or interned remote).
|
|
#[derive(Debug, Clone)]
|
|
pub struct ActorApUrls {
|
|
pub ap_id: String,
|
|
pub inbox_url: String,
|
|
}
|
|
```
|
|
|
|
Then inside `pub trait ActivityPubRepository`, add after `count_local_notes`:
|
|
|
|
```rust
|
|
/// Return the ActivityPub object URL for a thought, if one is stored.
|
|
/// Returns None for local thoughts (caller constructs the URL from base_url + thought_id).
|
|
async fn get_thought_ap_id(
|
|
&self,
|
|
thought_id: &ThoughtId,
|
|
) -> Result<Option<String>, DomainError>;
|
|
|
|
/// Return the AP actor URL and inbox URL for a user, if stored.
|
|
/// Returns None for users that have not been federated.
|
|
async fn get_actor_ap_urls(
|
|
&self,
|
|
user_id: &UserId,
|
|
) -> Result<Option<ActorApUrls>, DomainError>;
|
|
```
|
|
|
|
- [ ] **Step 2: Implement both methods in `PgActivityPubRepository`**
|
|
|
|
In `crates/adapters/postgres/src/activitypub.rs`, add the two implementations:
|
|
|
|
```rust
|
|
async fn get_thought_ap_id(
|
|
&self,
|
|
thought_id: &ThoughtId,
|
|
) -> Result<Option<String>, DomainError> {
|
|
sqlx::query_scalar::<_, String>(
|
|
"SELECT ap_id FROM thoughts WHERE id = $1 AND ap_id IS NOT NULL",
|
|
)
|
|
.bind(thought_id.as_uuid())
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.into_domain()
|
|
}
|
|
|
|
async fn get_actor_ap_urls(
|
|
&self,
|
|
user_id: &UserId,
|
|
) -> Result<Option<ActorApUrls>, DomainError> {
|
|
sqlx::query_as::<_, (String, String)>(
|
|
"SELECT ap_id, inbox_url FROM users \
|
|
WHERE id = $1 AND ap_id IS NOT NULL AND inbox_url IS NOT NULL",
|
|
)
|
|
.bind(user_id.as_uuid())
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.into_domain()
|
|
.map(|opt| {
|
|
opt.map(|(ap_id, inbox_url)| ActorApUrls { ap_id, inbox_url })
|
|
})
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add stub implementations in `TestStore`**
|
|
|
|
In `crates/domain/src/testing.rs`, add to the `ActivityPubRepository` impl for `TestStore`:
|
|
|
|
```rust
|
|
async fn get_thought_ap_id(
|
|
&self,
|
|
_thought_id: &ThoughtId,
|
|
) -> Result<Option<String>, DomainError> {
|
|
Ok(None)
|
|
}
|
|
|
|
async fn get_actor_ap_urls(
|
|
&self,
|
|
_user_id: &UserId,
|
|
) -> Result<Option<ActorApUrls>, DomainError> {
|
|
Ok(None)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Compile check**
|
|
|
|
```bash
|
|
cargo check --workspace 2>&1 | head -30
|
|
```
|
|
Expected: 0 errors.
|
|
|
|
---
|
|
|
|
### Task 2: Remove AP fields from `User` and `Thought` domain models
|
|
|
|
Now that the AP lookup methods exist, remove the AP-specific fields from the domain models and update all consumers.
|
|
|
|
**Files:**
|
|
- Modify: `crates/domain/src/models/user.rs`
|
|
- Modify: `crates/domain/src/models/thought.rs`
|
|
- Modify: `crates/adapters/postgres/src/user.rs` (UserRow → User mapping)
|
|
- Modify: `crates/adapters/postgres/src/thought.rs` (ThoughtRow → Thought mapping)
|
|
- Modify: `crates/adapters/postgres/src/feed.rs` (FeedRow → FeedEntry mapping)
|
|
- Modify: `crates/adapters/postgres-search/src/lib.rs` (FeedRow → FeedEntry mapping)
|
|
- Modify: `crates/application/src/services/federation_event.rs`
|
|
- Modify: `crates/domain/src/testing.rs`
|
|
|
|
- [ ] **Step 1: Remove from `User` model**
|
|
|
|
In `crates/domain/src/models/user.rs`, remove `ap_id` and `inbox_url` fields:
|
|
|
|
```rust
|
|
#[derive(Debug, Clone)]
|
|
pub struct User {
|
|
pub id: UserId,
|
|
pub username: Username,
|
|
pub email: Email,
|
|
pub password_hash: PasswordHash,
|
|
pub display_name: Option<String>,
|
|
pub bio: Option<String>,
|
|
pub avatar_url: Option<String>,
|
|
pub header_url: Option<String>,
|
|
pub custom_css: Option<String>,
|
|
pub local: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl User {
|
|
pub fn new_local(
|
|
id: UserId,
|
|
username: Username,
|
|
email: Email,
|
|
password_hash: PasswordHash,
|
|
) -> Self {
|
|
let now = Utc::now();
|
|
Self {
|
|
id,
|
|
username,
|
|
email,
|
|
password_hash,
|
|
display_name: None,
|
|
bio: None,
|
|
avatar_url: None,
|
|
header_url: None,
|
|
custom_css: None,
|
|
local: true,
|
|
created_at: now,
|
|
updated_at: now,
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Remove from `Thought` model**
|
|
|
|
In `crates/domain/src/models/thought.rs`, remove `ap_id` and `in_reply_to_url` fields:
|
|
|
|
```rust
|
|
#[derive(Debug, Clone)]
|
|
pub struct Thought {
|
|
pub id: ThoughtId,
|
|
pub user_id: UserId,
|
|
pub content: Content,
|
|
pub in_reply_to_id: Option<ThoughtId>,
|
|
pub visibility: Visibility,
|
|
pub content_warning: Option<String>,
|
|
pub sensitive: bool,
|
|
pub local: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
impl Thought {
|
|
pub fn new_local(
|
|
id: ThoughtId,
|
|
user_id: UserId,
|
|
content: Content,
|
|
in_reply_to_id: Option<ThoughtId>,
|
|
visibility: Visibility,
|
|
content_warning: Option<String>,
|
|
sensitive: bool,
|
|
) -> Self {
|
|
Self {
|
|
id,
|
|
user_id,
|
|
content,
|
|
in_reply_to_id,
|
|
visibility,
|
|
content_warning,
|
|
sensitive,
|
|
local: true,
|
|
created_at: Utc::now(),
|
|
updated_at: None,
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Update `UserRow → User` mapping in `postgres/src/user.rs`**
|
|
|
|
Remove the `ap_id` and `inbox_url` fields from `UserRow` and its `From<UserRow> for User` impl:
|
|
|
|
```rust
|
|
#[derive(sqlx::FromRow)]
|
|
pub struct UserRow {
|
|
pub id: uuid::Uuid,
|
|
pub username: String,
|
|
pub email: String,
|
|
pub password_hash: String,
|
|
pub display_name: Option<String>,
|
|
pub bio: Option<String>,
|
|
pub avatar_url: Option<String>,
|
|
pub header_url: Option<String>,
|
|
pub custom_css: Option<String>,
|
|
pub local: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl From<UserRow> for User {
|
|
fn from(r: UserRow) -> Self {
|
|
User {
|
|
id: UserId::from_uuid(r.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.local,
|
|
created_at: r.created_at,
|
|
updated_at: r.updated_at,
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Update `USER_SELECT` to remove the columns:
|
|
```rust
|
|
pub const USER_SELECT: &str =
|
|
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
|
|
custom_css,local,created_at,updated_at FROM users";
|
|
```
|
|
|
|
- [ ] **Step 4: Update `ThoughtRow → Thought` mapping in `postgres/src/thought.rs`**
|
|
|
|
Remove `ap_id` and `in_reply_to_url` from `ThoughtRow` and its `From` impl. The `ap_id` column stays in the DB — we just don't pull it into the domain model via this path. The `SELECT` query changes too:
|
|
|
|
```rust
|
|
#[derive(sqlx::FromRow)]
|
|
struct ThoughtRow {
|
|
pub id: uuid::Uuid,
|
|
pub user_id: uuid::Uuid,
|
|
pub content: String,
|
|
pub in_reply_to_id: Option<uuid::Uuid>,
|
|
pub visibility: String,
|
|
pub content_warning: Option<String>,
|
|
pub sensitive: bool,
|
|
pub local: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: Option<DateTime<Utc>>,
|
|
}
|
|
```
|
|
|
|
Update the `SELECT` constant and `From` impl to match (remove `ap_id`, `in_reply_to_url` from both). Also update the `save` method — remove the `ap_id` binding from INSERT. The `ap_id` column in the DB gets set to NULL for local thoughts; remote thoughts' ap_id is set by `accept_note` in the AP adapter.
|
|
|
|
- [ ] **Step 5: Update `FeedRow` mappings in `feed.rs` and `postgres-search/lib.rs`**
|
|
|
|
In `postgres/src/feed.rs`, remove `t_ap_id` and `in_reply_to_url` from `FeedRow` and from the SELECT query. In `postgres-search/src/lib.rs`, same removal.
|
|
|
|
The `FeedEntry.thought` field is now a `Thought` without `ap_id`. Adapters that need AP context use `ActivityPubRepository`.
|
|
|
|
- [ ] **Step 6: Update `FederationEventService` to use AP lookups**
|
|
|
|
In `crates/application/src/services/federation_event.rs`, replace all reads of `thought.ap_id` and `user.inbox_url` with calls to `self.ap_repo`:
|
|
|
|
Replace the `object_ap_id` helper:
|
|
```rust
|
|
async fn object_ap_id(&self, thought: &Thought, thought_id: &ThoughtId) -> Result<String, DomainError> {
|
|
if !thought.local {
|
|
if let Some(ap_id) = self.ap_repo.get_thought_ap_id(thought_id).await? {
|
|
return Ok(ap_id);
|
|
}
|
|
}
|
|
Ok(format!("{}/thoughts/{}", self.base_url, thought_id))
|
|
}
|
|
```
|
|
|
|
Note: this method is now `async`. Update all callers to `.await`.
|
|
|
|
For `ThoughtCreated` — the `in_reply_to_url` resolution changes. Instead of reading `thought.ap_id` of the parent, call `self.ap_repo.get_thought_ap_id(reply_id).await?` and fall back to constructing the URL.
|
|
|
|
For `LikeAdded` / `LikeRemoved` — replace `thought.ap_id.is_some()` check and `thought.ap_id.unwrap()` / `user.inbox_url.unwrap()` with:
|
|
```rust
|
|
let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? {
|
|
Some(id) => id,
|
|
None => return Ok(()), // local thought — no federation needed
|
|
};
|
|
let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? {
|
|
Some(u) => u,
|
|
None => return Ok(()),
|
|
};
|
|
self.ap.broadcast_like(user_id, &thought_ap_id, &actor_urls.inbox_url).await
|
|
```
|
|
|
|
For `BoostAdded` / `BoostRemoved` — same pattern, use `object_ap_id` (now async).
|
|
|
|
- [ ] **Step 7: Update `TestStore` for fields removed from models**
|
|
|
|
In `crates/domain/src/testing.rs`, update any place that constructs `User` or `Thought` with `ap_id`/`inbox_url`/`in_reply_to_url` fields. These fields no longer exist on the structs.
|
|
|
|
- [ ] **Step 8: Compile check**
|
|
```bash
|
|
cargo check --workspace 2>&1 | head -40
|
|
```
|
|
|
|
Fix all errors before moving on.
|
|
|
|
- [ ] **Step 9: Commit**
|
|
```bash
|
|
git add -p
|
|
git commit -m "refactor(domain): remove AP fields from User and Thought; use ActivityPubRepository lookups"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Remove AP infrastructure events from `DomainEvent`; add `FederationSchedulerPort`
|
|
|
|
`FetchRemoteActorPosts` and `FetchActorConnections` in `DomainEvent` carry raw AP URLs — infrastructure, not domain. Replace them with a narrow port that the application layer calls directly.
|
|
|
|
**Files:**
|
|
- Modify: `crates/domain/src/events.rs`
|
|
- Modify: `crates/domain/src/ports.rs`
|
|
- Modify: `crates/domain/src/testing.rs`
|
|
- Modify: `crates/application/src/use_cases/federation_management.rs`
|
|
- Modify: `crates/application/src/services/federation_event.rs`
|
|
- Modify: `crates/adapters/activitypub-base/src/service.rs` (implements the new port)
|
|
- Modify: `crates/presentation/src/state.rs`
|
|
- Modify: `crates/bootstrap/src/factory.rs`
|
|
|
|
- [ ] **Step 1: Add `FederationSchedulerPort` to `ports.rs`**
|
|
|
|
```rust
|
|
#[async_trait]
|
|
pub trait FederationSchedulerPort: Send + Sync {
|
|
async fn schedule_actor_posts_fetch(
|
|
&self,
|
|
actor_ap_url: &str,
|
|
outbox_url: &str,
|
|
) -> Result<(), DomainError>;
|
|
|
|
async fn schedule_connections_fetch(
|
|
&self,
|
|
actor_ap_url: &str,
|
|
collection_url: &str,
|
|
connection_type: &str,
|
|
page: u32,
|
|
) -> Result<(), DomainError>;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Remove the two AP events from `events.rs`**
|
|
|
|
In `crates/domain/src/events.rs`, delete the `FetchRemoteActorPosts` and `FetchActorConnections` variants entirely. The enum becomes:
|
|
|
|
```rust
|
|
#[derive(Debug, Clone)]
|
|
pub enum DomainEvent {
|
|
ThoughtCreated { thought_id: ThoughtId, user_id: UserId, in_reply_to_id: Option<ThoughtId> },
|
|
ThoughtDeleted { thought_id: ThoughtId, user_id: UserId },
|
|
ThoughtUpdated { thought_id: ThoughtId, user_id: UserId },
|
|
LikeAdded { like_id: LikeId, user_id: UserId, thought_id: ThoughtId },
|
|
LikeRemoved { user_id: UserId, thought_id: ThoughtId },
|
|
BoostAdded { boost_id: BoostId, user_id: UserId, thought_id: ThoughtId },
|
|
BoostRemoved { user_id: UserId, thought_id: ThoughtId },
|
|
FollowRequested { follower_id: UserId, following_id: UserId },
|
|
FollowAccepted { follower_id: UserId, following_id: UserId },
|
|
FollowRejected { follower_id: UserId, following_id: UserId },
|
|
Unfollowed { follower_id: UserId, following_id: UserId },
|
|
UserBlocked { blocker_id: UserId, blocked_id: UserId },
|
|
UserUnblocked { blocker_id: UserId, blocked_id: UserId },
|
|
UserRegistered { user_id: UserId },
|
|
ProfileUpdated { user_id: UserId },
|
|
MentionReceived { thought_id: ThoughtId, mentioned_user_id: UserId, author_user_id: UserId },
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Update `federation_management.rs` to call the scheduler port directly**
|
|
|
|
In `crates/application/src/use_cases/federation_management.rs`, add a `scheduler: &dyn FederationSchedulerPort` parameter to `get_remote_actor_posts` and `get_actor_connections_page`, and call it instead of publishing an event:
|
|
|
|
```rust
|
|
pub async fn get_remote_actor_posts(
|
|
federation: &dyn FederationActionPort,
|
|
ap_repo: &dyn ActivityPubRepository,
|
|
feed: &dyn FeedRepository,
|
|
scheduler: &dyn FederationSchedulerPort, // NEW
|
|
handle: &str,
|
|
page: PageParams,
|
|
viewer_id: Option<&UserId>,
|
|
) -> Result<(Paginated<FeedEntry>, bool), DomainError> {
|
|
let actor = federation.lookup_actor(handle).await?;
|
|
let actor_url = url::Url::parse(&actor.url)
|
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
|
let author_id = match ap_repo.find_remote_actor_id(&actor_url).await? {
|
|
Some(id) => id,
|
|
None => ap_repo.intern_remote_actor(&actor_url).await?,
|
|
};
|
|
let result = get_user_feed(feed, &author_id, page, viewer_id).await?;
|
|
// Schedule background fetch if actor has outbox
|
|
if let Some(ref outbox_url) = actor.outbox_url {
|
|
let _ = scheduler
|
|
.schedule_actor_posts_fetch(&actor.url, outbox_url)
|
|
.await;
|
|
}
|
|
let has_more = !result.items.is_empty();
|
|
Ok((result, has_more))
|
|
}
|
|
|
|
pub async fn get_actor_connections_page(
|
|
federation: &dyn FederationActionPort,
|
|
connections: &dyn RemoteActorConnectionRepository,
|
|
scheduler: &dyn FederationSchedulerPort, // NEW
|
|
handle: &str,
|
|
connection_type: &str,
|
|
page: u32,
|
|
) -> Result<(Vec<ActorConnectionSummary>, bool), DomainError> {
|
|
// ... existing lookup logic ...
|
|
// Replace event publish with:
|
|
if stale {
|
|
let _ = scheduler
|
|
.schedule_connections_fetch(&actor.url, &collection_url, connection_type, page)
|
|
.await;
|
|
}
|
|
// ...
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Update `FederationEventService` — remove the two AP event arms**
|
|
|
|
In `federation_event.rs`, delete the `DomainEvent::FetchRemoteActorPosts` and `DomainEvent::FetchActorConnections` match arms. The service no longer handles infrastructure fetch commands.
|
|
|
|
Also remove the `federation_action` and `remote_actor_connections` fields from `FederationEventService` if they're only used by those two removed arms. Check: they're also used nowhere else. Remove them from the struct definition.
|
|
|
|
- [ ] **Step 5: Implement `FederationSchedulerPort` in `ActivityPubService`**
|
|
|
|
In `crates/adapters/activitypub-base/src/service.rs`, add an impl:
|
|
|
|
```rust
|
|
#[async_trait::async_trait]
|
|
impl domain::ports::FederationSchedulerPort for ActivityPubService {
|
|
async fn schedule_actor_posts_fetch(
|
|
&self,
|
|
actor_ap_url: &str,
|
|
outbox_url: &str,
|
|
) -> Result<(), domain::errors::DomainError> {
|
|
// Re-use the existing event publisher to publish the background task,
|
|
// but now this is an adapter concern, not a domain event.
|
|
self.events
|
|
.publish(&domain::events::DomainEvent::ThoughtCreated {
|
|
// ... actually: use a separate internal channel or directly spawn
|
|
})
|
|
.await
|
|
}
|
|
}
|
|
```
|
|
|
|
**Note:** The exact implementation depends on how the worker picks up background tasks. If the worker currently subscribes to `DomainEvent`, and we've removed these variants, the worker needs a new subscription path. The simplest compatible approach: have `ActivityPubService` keep an internal sender for these tasks (the existing NATS/channel mechanism), and the worker subscribes to that same mechanism. Read `crates/adapters/nats/src/lib.rs` to understand the existing plumbing, then implement accordingly.
|
|
|
|
- [ ] **Step 6: Add `FederationSchedulerPort` to `AppState` and `TestStore`**
|
|
|
|
`AppState` (`crates/presentation/src/state.rs`):
|
|
```rust
|
|
pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
|
|
```
|
|
|
|
`TestStore` (`crates/domain/src/testing.rs`) — add a no-op impl:
|
|
```rust
|
|
#[async_trait]
|
|
impl FederationSchedulerPort for TestStore {
|
|
async fn schedule_actor_posts_fetch(&self, _: &str, _: &str) -> Result<(), DomainError> {
|
|
Ok(())
|
|
}
|
|
async fn schedule_connections_fetch(&self, _: &str, _: &str, _: &str, _: u32) -> Result<(), DomainError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Wire up in `bootstrap/src/factory.rs`**
|
|
|
|
Add `federation_scheduler: Arc::new(infra.ap_service.clone())` to the `AppState` construction (assuming `ActivityPubService` implements the port).
|
|
|
|
- [ ] **Step 8: Update handlers that call `get_remote_actor_posts` / `get_actor_connections_page`**
|
|
|
|
In `crates/presentation/src/handlers/federation_actors.rs`, pass `&*s.federation_scheduler` to the new parameter.
|
|
|
|
- [ ] **Step 9: Compile check**
|
|
```bash
|
|
cargo check --workspace 2>&1 | head -40
|
|
```
|
|
|
|
- [ ] **Step 10: Commit**
|
|
```bash
|
|
git commit -m "refactor(domain): remove FetchRemoteActorPosts/FetchActorConnections from DomainEvent; add FederationSchedulerPort"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2 — CQRS Port Split
|
|
|
|
### Task 4: Split `UserRepository` into `UserReader + UserWriter`
|
|
|
|
**Files:**
|
|
- Modify: `crates/domain/src/ports.rs`
|
|
- Modify: `crates/adapters/postgres/src/user.rs`
|
|
- Modify: `crates/domain/src/testing.rs`
|
|
- Modify: `crates/application/src/use_cases/*.rs` (update function signatures)
|
|
|
|
- [ ] **Step 1: Define `UserReader` and `UserWriter` in `ports.rs`**
|
|
|
|
Replace the existing `UserRepository` trait with three traits:
|
|
|
|
```rust
|
|
#[async_trait]
|
|
pub trait UserReader: Send + Sync {
|
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
|
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
|
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
|
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
|
|
async fn count(&self) -> Result<i64, DomainError>;
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait UserWriter: Send + Sync {
|
|
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
|
async fn update_profile(
|
|
&self,
|
|
user_id: &UserId,
|
|
display_name: Option<String>,
|
|
bio: Option<String>,
|
|
avatar_url: Option<String>,
|
|
header_url: Option<String>,
|
|
custom_css: Option<String>,
|
|
) -> Result<(), DomainError>;
|
|
}
|
|
|
|
/// Combined supertrait — kept so `AppState` needs no change.
|
|
/// Postgres adapter implements all three; use cases declare the narrower bound they need.
|
|
pub trait UserRepository: UserReader + UserWriter {}
|
|
impl<T: UserReader + UserWriter> UserRepository for T {}
|
|
```
|
|
|
|
- [ ] **Step 2: `PgUserRepository` implements `UserReader` and `UserWriter` separately**
|
|
|
|
In `crates/adapters/postgres/src/user.rs`, split the single `impl UserRepository` into:
|
|
|
|
```rust
|
|
#[async_trait]
|
|
impl UserReader for PgUserRepository {
|
|
// all read methods
|
|
}
|
|
|
|
#[async_trait]
|
|
impl UserWriter for PgUserRepository {
|
|
// save, update_profile
|
|
}
|
|
```
|
|
|
|
The blanket impl in ports.rs makes `PgUserRepository: UserRepository` automatically.
|
|
|
|
- [ ] **Step 3: Update use case function signatures to declare the narrowest bound they need**
|
|
|
|
In each use case file, change `users: &dyn UserRepository` to the narrowest applicable bound:
|
|
|
|
| Use case | New bound |
|
|
|---|---|
|
|
| `get_user`, `get_user_by_username`, `get_user_by_id_or_username`, `get_top_friends` | `&dyn UserReader` |
|
|
| `register_user` | `&dyn UserWriter` |
|
|
| `update_profile` | `&dyn UserWriter` (for the write) + `&dyn UserReader` (to fetch back) |
|
|
| `follow_actor`, `unfollow_actor`, `block_by_username` | `&dyn UserReader` (lookup only) |
|
|
|
|
Handlers still pass `&*s.users` (an `Arc<dyn UserRepository>`), which satisfies any narrower bound via Rust's trait coercion.
|
|
|
|
- [ ] **Step 4: Update `TestStore` — split its user impl to satisfy both traits**
|
|
|
|
```rust
|
|
#[async_trait]
|
|
impl UserReader for TestStore { /* existing read methods */ }
|
|
|
|
#[async_trait]
|
|
impl UserWriter for TestStore { /* existing write methods */ }
|
|
```
|
|
|
|
- [ ] **Step 5: Compile check**
|
|
```bash
|
|
cargo check --workspace 2>&1 | head -30
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
```bash
|
|
git commit -m "refactor(ports): CQRS split — UserReader + UserWriter supertrait"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Split `FederationActionPort` into four focused sub-ports
|
|
|
|
**Files:**
|
|
- Modify: `crates/domain/src/ports.rs`
|
|
- Modify: `crates/domain/src/testing.rs`
|
|
- Modify: `crates/adapters/activitypub-base/src/service.rs`
|
|
- Modify: `crates/application/src/use_cases/federation_management.rs`
|
|
- Modify: `crates/application/src/use_cases/social.rs`
|
|
- Modify: `crates/application/src/use_cases/profile.rs`
|
|
- Modify: `crates/presentation/src/state.rs`
|
|
|
|
- [ ] **Step 1: Define the four sub-ports in `ports.rs`**
|
|
|
|
Replace `FederationActionPort` with:
|
|
|
|
```rust
|
|
#[async_trait]
|
|
pub trait FederationLookupPort: Send + Sync {
|
|
async fn lookup_actor(&self, handle: &str) -> Result<RemoteActor, DomainError>;
|
|
async fn actor_json(&self, user_id: &UserId) -> Result<String, DomainError>;
|
|
async fn followers_collection_json(&self, user_id: &UserId, page: Option<u32>) -> Result<String, DomainError>;
|
|
async fn following_collection_json(&self, user_id: &UserId, page: Option<u32>) -> Result<String, DomainError>;
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait FederationFollowPort: Send + Sync {
|
|
async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
|
|
async fn unfollow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
|
|
async fn get_remote_following(&self, user_id: &UserId) -> Result<Vec<RemoteActor>, DomainError>;
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait FederationFollowRequestPort: Send + Sync {
|
|
async fn get_pending_followers(&self, user_id: &UserId) -> Result<Vec<RemoteActor>, DomainError>;
|
|
async fn accept_follow_request(&self, user_id: &UserId, actor_url: &str) -> Result<(), DomainError>;
|
|
async fn reject_follow_request(&self, user_id: &UserId, actor_url: &str) -> Result<(), DomainError>;
|
|
async fn get_remote_followers(&self, user_id: &UserId) -> Result<Vec<RemoteActor>, DomainError>;
|
|
async fn remove_remote_follower(&self, user_id: &UserId, actor_url: &str) -> Result<(), DomainError>;
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait FederationFetchPort: Send + Sync {
|
|
async fn fetch_outbox_page(&self, outbox_url: &str, page: u32) -> Result<Vec<crate::models::remote_note::RemoteNote>, DomainError>;
|
|
async fn fetch_actor_urls_from_collection(&self, collection_url: &str) -> Result<Vec<String>, DomainError>;
|
|
async fn resolve_actor_profiles(&self, urls: Vec<String>) -> Vec<crate::models::actor_connection_summary::ActorConnectionSummary>;
|
|
}
|
|
|
|
/// Combined supertrait — `AppState.federation` stays as a single field.
|
|
pub trait FederationActionPort: FederationLookupPort + FederationFollowPort + FederationFollowRequestPort + FederationFetchPort {}
|
|
impl<T: FederationLookupPort + FederationFollowPort + FederationFollowRequestPort + FederationFetchPort> FederationActionPort for T {}
|
|
```
|
|
|
|
- [ ] **Step 2: Split `ActivityPubService` impl into four sub-impls**
|
|
|
|
In `crates/adapters/activitypub-base/src/service.rs`, replace `impl FederationActionPort` with four separate impls, each containing only the methods for that sub-port.
|
|
|
|
- [ ] **Step 3: Update use case signatures to declare the narrowest port they need**
|
|
|
|
| Use case / handler | New bound |
|
|
|---|---|
|
|
| `get_user` (AP actor JSON path) | `&dyn FederationLookupPort` |
|
|
| `follow_actor`, `unfollow_actor` | `&dyn FederationFollowPort` |
|
|
| `get_top_friends` (no federation) | remove federation param if unused |
|
|
| `list_pending_requests`, `accept/reject`, `list_remote_followers/following` | `&dyn FederationFollowRequestPort` |
|
|
| `get_remote_actor_posts`, `get_actor_connections_page` | `&dyn FederationLookupPort + &dyn FederationFetchPort` |
|
|
| `remove_remote_following` (federation_management) | `&dyn FederationFollowPort` |
|
|
|
|
Handlers pass `&*s.federation` which satisfies all bounds.
|
|
|
|
- [ ] **Step 4: Update `TestStore` — split impl into four**
|
|
|
|
```rust
|
|
#[async_trait]
|
|
impl FederationLookupPort for TestStore { /* ... */ }
|
|
#[async_trait]
|
|
impl FederationFollowPort for TestStore { /* ... */ }
|
|
#[async_trait]
|
|
impl FederationFollowRequestPort for TestStore { /* ... */ }
|
|
#[async_trait]
|
|
impl FederationFetchPort for TestStore { /* ... */ }
|
|
```
|
|
|
|
- [ ] **Step 5: Compile check**
|
|
```bash
|
|
cargo check --workspace 2>&1 | head -40
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
```bash
|
|
git commit -m "refactor(ports): CQRS split — FederationActionPort into four focused sub-ports"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 3 — Domain Model Quality
|
|
|
|
### Task 6: Algebraic `Notification` type (make invalid states unrepresentable)
|
|
|
|
**Files:**
|
|
- Modify: `crates/domain/src/models/notification.rs`
|
|
- Modify: `crates/domain/src/ports.rs` (`NotificationRepository::save` param type stays `&Notification`)
|
|
- Modify: `crates/adapters/postgres/src/notification.rs`
|
|
- Modify: `crates/application/src/services/notification_event.rs`
|
|
- Modify: `crates/domain/src/testing.rs`
|
|
|
|
- [ ] **Step 1: Redefine `Notification` as an algebraic type**
|
|
|
|
Replace `crates/domain/src/models/notification.rs` entirely:
|
|
|
|
```rust
|
|
use crate::value_objects::{NotificationId, ThoughtId, UserId};
|
|
use chrono::{DateTime, Utc};
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum NotificationKind {
|
|
Like { thought_id: ThoughtId, from_user_id: UserId },
|
|
Boost { thought_id: ThoughtId, from_user_id: UserId },
|
|
Reply { thought_id: ThoughtId, from_user_id: UserId },
|
|
Mention { thought_id: ThoughtId, from_user_id: UserId },
|
|
Follow { from_user_id: UserId },
|
|
}
|
|
|
|
impl NotificationKind {
|
|
pub fn from_user_id(&self) -> &UserId {
|
|
match self {
|
|
Self::Like { from_user_id, .. } => from_user_id,
|
|
Self::Boost { from_user_id, .. } => from_user_id,
|
|
Self::Reply { from_user_id, .. } => from_user_id,
|
|
Self::Mention { from_user_id, .. } => from_user_id,
|
|
Self::Follow { from_user_id } => from_user_id,
|
|
}
|
|
}
|
|
|
|
pub fn thought_id(&self) -> Option<&ThoughtId> {
|
|
match self {
|
|
Self::Like { thought_id, .. } => Some(thought_id),
|
|
Self::Boost { thought_id, .. } => Some(thought_id),
|
|
Self::Reply { thought_id, .. } => Some(thought_id),
|
|
Self::Mention { thought_id, .. } => Some(thought_id),
|
|
Self::Follow { .. } => None,
|
|
}
|
|
}
|
|
|
|
pub fn kind_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Like { .. } => "like",
|
|
Self::Boost { .. } => "boost",
|
|
Self::Reply { .. } => "reply",
|
|
Self::Mention { .. } => "mention",
|
|
Self::Follow { .. } => "follow",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Notification {
|
|
pub id: NotificationId,
|
|
pub user_id: UserId,
|
|
pub kind: NotificationKind,
|
|
pub read: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Update `postgres/src/notification.rs`**
|
|
|
|
The DB row still has `notification_type`, `from_user_id`, `thought_id`. Add a `NotificationRow → Notification` conversion that constructs the correct `NotificationKind` variant:
|
|
|
|
```rust
|
|
#[derive(sqlx::FromRow)]
|
|
struct NotificationRow {
|
|
id: uuid::Uuid,
|
|
user_id: uuid::Uuid,
|
|
notification_type: String,
|
|
from_user_id: Option<uuid::Uuid>,
|
|
thought_id: Option<uuid::Uuid>,
|
|
read: bool,
|
|
created_at: DateTime<Utc>,
|
|
}
|
|
|
|
fn row_to_notification(r: NotificationRow) -> Result<Notification, DomainError> {
|
|
let from_user_id = r.from_user_id
|
|
.map(UserId::from_uuid)
|
|
.ok_or_else(|| DomainError::Internal("notification missing from_user_id".into()))?;
|
|
|
|
let kind = match r.notification_type.as_str() {
|
|
"follow" => NotificationKind::Follow { from_user_id },
|
|
other => {
|
|
let thought_id = r.thought_id
|
|
.map(ThoughtId::from_uuid)
|
|
.ok_or_else(|| DomainError::Internal(
|
|
format!("notification type '{other}' missing thought_id")
|
|
))?;
|
|
match other {
|
|
"like" => NotificationKind::Like { thought_id, from_user_id },
|
|
"boost" => NotificationKind::Boost { thought_id, from_user_id },
|
|
"reply" => NotificationKind::Reply { thought_id, from_user_id },
|
|
"mention" => NotificationKind::Mention { thought_id, from_user_id },
|
|
_ => return Err(DomainError::Internal(
|
|
format!("unknown notification type: {other}")
|
|
)),
|
|
}
|
|
}
|
|
};
|
|
|
|
Ok(Notification {
|
|
id: NotificationId::from_uuid(r.id),
|
|
user_id: UserId::from_uuid(r.user_id),
|
|
kind,
|
|
read: r.read,
|
|
created_at: r.created_at,
|
|
})
|
|
}
|
|
```
|
|
|
|
Update `save` to write the new fields from `n.kind`:
|
|
```rust
|
|
async fn save(&self, n: &Notification) -> Result<(), DomainError> {
|
|
sqlx::query(
|
|
"INSERT INTO notifications(id,user_id,notification_type,from_user_id,thought_id,read,created_at)
|
|
VALUES($1,$2,$3,$4,$5,$6,$7)
|
|
ON CONFLICT(id) DO NOTHING"
|
|
)
|
|
.bind(n.id.as_uuid())
|
|
.bind(n.user_id.as_uuid())
|
|
.bind(n.kind.kind_str())
|
|
.bind(n.kind.from_user_id().as_uuid())
|
|
.bind(n.kind.thought_id().map(|t| t.as_uuid()))
|
|
.bind(n.read)
|
|
.bind(n.created_at)
|
|
.execute(&self.pool)
|
|
.await
|
|
.into_domain()
|
|
.map(|_| ())
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Update `notification_event.rs` (the service that creates notifications)**
|
|
|
|
Read `crates/application/src/services/notification_event.rs` and update all `Notification { notification_type, from_user_id: Some(...), thought_id: Some(...), ... }` constructions to use the new `NotificationKind` enum:
|
|
|
|
```rust
|
|
// Before
|
|
Notification {
|
|
id: NotificationId::new(),
|
|
user_id: owner_id.clone(),
|
|
notification_type: NotificationType::Like,
|
|
from_user_id: Some(liker_id.clone()),
|
|
thought_id: Some(thought_id.clone()),
|
|
read: false,
|
|
created_at: Utc::now(),
|
|
}
|
|
|
|
// After
|
|
Notification {
|
|
id: NotificationId::new(),
|
|
user_id: owner_id.clone(),
|
|
kind: NotificationKind::Like { thought_id: thought_id.clone(), from_user_id: liker_id.clone() },
|
|
read: false,
|
|
created_at: Utc::now(),
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Update handlers that read `Notification` fields**
|
|
|
|
In `crates/presentation/src/handlers/notifications.rs` and any response mapping — change `.notification_type` / `.from_user_id` / `.thought_id` reads to pattern match on `.kind`.
|
|
|
|
- [ ] **Step 5: Remove `NotificationType` enum** (the old one in `notification.rs`) — it is fully replaced by `NotificationKind`.
|
|
|
|
- [ ] **Step 6: Compile check**
|
|
```bash
|
|
cargo check --workspace 2>&1 | head -30
|
|
```
|
|
|
|
- [ ] **Step 7: Commit**
|
|
```bash
|
|
git commit -m "refactor(domain): algebraic Notification type — invalid states now unrepresentable"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Make `from_db_str` return `Result` instead of silently defaulting
|
|
|
|
**Files:**
|
|
- Modify: `crates/domain/src/models/thought.rs`
|
|
- Modify: `crates/domain/src/models/social.rs`
|
|
- Modify: `crates/adapters/postgres/src/thought.rs`
|
|
- Modify: `crates/adapters/postgres/src/feed.rs`
|
|
- Modify: `crates/adapters/postgres/src/follow.rs`
|
|
- Modify: `crates/adapters/postgres-search/src/lib.rs`
|
|
|
|
- [ ] **Step 1: Change `Visibility::from_db_str` to return `Result`**
|
|
|
|
In `crates/domain/src/models/thought.rs`:
|
|
|
|
```rust
|
|
impl Visibility {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Public => "public",
|
|
Self::Followers => "followers",
|
|
Self::Unlisted => "unlisted",
|
|
Self::Direct => "direct",
|
|
}
|
|
}
|
|
|
|
pub fn from_db_str(s: &str) -> Result<Self, crate::errors::DomainError> {
|
|
match s {
|
|
"public" => Ok(Self::Public),
|
|
"followers" => Ok(Self::Followers),
|
|
"unlisted" => Ok(Self::Unlisted),
|
|
"direct" => Ok(Self::Direct),
|
|
other => Err(crate::errors::DomainError::Internal(
|
|
format!("unknown visibility value in DB: '{other}'")
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Change `FollowState::from_db_str` to return `Result`**
|
|
|
|
In `crates/domain/src/models/social.rs`:
|
|
|
|
```rust
|
|
impl FollowState {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Pending => "pending",
|
|
Self::Accepted => "accepted",
|
|
Self::Rejected => "rejected",
|
|
}
|
|
}
|
|
|
|
pub fn from_db_str(s: &str) -> Result<Self, crate::errors::DomainError> {
|
|
match s {
|
|
"pending" => Ok(Self::Pending),
|
|
"accepted" => Ok(Self::Accepted),
|
|
"rejected" => Ok(Self::Rejected),
|
|
other => Err(crate::errors::DomainError::Internal(
|
|
format!("unknown follow_state value in DB: '{other}'")
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Update all callers**
|
|
|
|
In each postgres adapter file, every call to `Visibility::from_db_str(s)` or `FollowState::from_db_str(s)` now returns a `Result`. Callers use `?` to propagate:
|
|
|
|
```rust
|
|
// Before
|
|
visibility: Visibility::from_db_str(&r.visibility),
|
|
// After
|
|
visibility: Visibility::from_db_str(&r.visibility)?,
|
|
```
|
|
|
|
Do this in: `thought.rs`, `feed.rs`, `follow.rs`, `postgres-search/lib.rs`.
|
|
|
|
- [ ] **Step 4: Compile check**
|
|
```bash
|
|
cargo check --workspace 2>&1 | head -20
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
```bash
|
|
git commit -m "fix(domain): from_db_str returns Result — unknown DB values are now errors, not silent defaults"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4 — Architecture Cleanup
|
|
|
|
### Task 8: Remove pass-through use cases
|
|
|
|
Use cases `search_thoughts`, `search_users` are single-line delegations with no business logic. Remove them and call the port directly from handlers.
|
|
|
|
**Files:**
|
|
- Delete: `crates/application/src/use_cases/search.rs`
|
|
- Modify: `crates/application/src/use_cases/mod.rs`
|
|
- Modify: `crates/presentation/src/handlers/feed.rs`
|
|
|
|
- [ ] **Step 1: Delete `search.rs` and remove from `mod.rs`**
|
|
|
|
```bash
|
|
rm crates/application/src/use_cases/search.rs
|
|
```
|
|
|
|
In `crates/application/src/use_cases/mod.rs`, remove `pub mod search;`.
|
|
|
|
- [ ] **Step 2: Update `feed.rs` handler to call the port directly**
|
|
|
|
In `crates/presentation/src/handlers/feed.rs`, replace:
|
|
```rust
|
|
use application::use_cases::search::{search_thoughts, search_users};
|
|
// ...
|
|
search_thoughts(&*s.search, &query, ...).await?
|
|
```
|
|
with:
|
|
```rust
|
|
s.search.search_thoughts(&query, &page, viewer.as_ref()).await?
|
|
```
|
|
|
|
Same for `search_users`:
|
|
```rust
|
|
s.search.search_users(&query, &page).await?
|
|
```
|
|
|
|
- [ ] **Step 3: Compile check and commit**
|
|
```bash
|
|
cargo check --workspace 2>&1 | head -20
|
|
git commit -m "refactor(application): remove pass-through search use cases — handlers call port directly"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Deduplicate actor resolution logic
|
|
|
|
`profile::get_user_by_id_or_username` and `social::follow_actor`/`unfollow_actor` both do "UUID or username?" routing independently. The social use cases' version (`@handle` detection) is already correct and distinct, but the presentation layer has a duplicate `resolve_user_id` that was fixed earlier. Verify no remaining duplication and consolidate if any remains.
|
|
|
|
**Files:**
|
|
- Modify: `crates/application/src/use_cases/profile.rs` (if needed)
|
|
- Modify: `crates/presentation/src/handlers/feed.rs` (verify)
|
|
|
|
- [ ] **Step 1: Audit current state**
|
|
|
|
Read `profile.rs::get_user_by_id_or_username` and `feed.rs::get_following_handler` / `get_followers_handler`. Confirm `feed.rs` already calls `get_user_by_id_or_username` (this was fixed in a prior session).
|
|
|
|
- [ ] **Step 2: Document the two distinct resolution patterns**
|
|
|
|
- `get_user_by_id_or_username` — UUID or local username (AP actor URL routing)
|
|
- `follow_actor` — `@handle@domain` vs local username (AP follow routing)
|
|
|
|
These are intentionally different. No merge needed. Verify there are no other copies.
|
|
|
|
```bash
|
|
grep -rn "parse_str\|parse::<uuid" crates/application/src/ crates/presentation/src/
|
|
```
|
|
|
|
If no other UUID-parse-then-username-fallback patterns appear outside these two, the task is complete.
|
|
|
|
- [ ] **Step 3: Commit if any changes made, otherwise skip**
|
|
|
|
---
|
|
|
|
### Task 10: Clean up `RemoteActor` — strip AP-specific fields
|
|
|
|
`RemoteActor` in `domain/src/models/remote_actor.rs` carries AP infrastructure fields (`inbox_url`, `shared_inbox_url`, `public_key`, etc.). The domain layer only cares about the identity of a remote user, not AP delivery details.
|
|
|
|
**Files:**
|
|
- Modify: `crates/domain/src/models/remote_actor.rs`
|
|
- Modify: `crates/domain/src/ports.rs` (`RemoteActorRepository`)
|
|
- Modify: `crates/adapters/postgres/src/remote_actor.rs`
|
|
- Modify: `crates/adapters/activitypub-base/src/repository.rs` (has its own `RemoteActor`)
|
|
- Modify: `crates/presentation/src/handlers/federation_management.rs` (response mapping)
|
|
|
|
- [ ] **Step 1: Slim down domain `RemoteActor`**
|
|
|
|
In `crates/domain/src/models/remote_actor.rs`, keep only the fields meaningful at the domain level:
|
|
|
|
```rust
|
|
use chrono::{DateTime, Utc};
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct RemoteActor {
|
|
pub url: String,
|
|
pub handle: String,
|
|
pub display_name: Option<String>,
|
|
pub avatar_url: Option<String>,
|
|
pub bio: Option<String>,
|
|
pub banner_url: Option<String>,
|
|
pub also_known_as: Option<String>,
|
|
pub outbox_url: Option<String>,
|
|
pub followers_url: Option<String>,
|
|
pub following_url: Option<String>,
|
|
pub attachment: Vec<(String, String)>,
|
|
pub last_fetched_at: DateTime<Utc>,
|
|
}
|
|
```
|
|
|
|
Removed: `inbox_url`, `shared_inbox_url`, `public_key`.
|
|
|
|
- [ ] **Step 2: Update `RemoteActorRepository` port if it uses the removed fields**
|
|
|
|
The `upsert` and `find_by_url` methods accept/return the slimmed model. Verify the postgres `remote_actor.rs` adapter maps correctly (the DB still has inbox_url column; just don't pull it into the domain model).
|
|
|
|
- [ ] **Step 3: Fix compilation errors from removed fields**
|
|
|
|
The main consumer is `FederationActionPort.lookup_actor()` which returns `RemoteActor`. The AP adapter (`ActivityPubService`) constructs `RemoteActor` from fetched data — update it to not set the removed fields.
|
|
|
|
`InboxUrl` and `shared_inbox_url` are needed by the AP delivery layer (`OutboundFederationPort`). Those should use the `activitypub-base`'s own `RemoteActor` struct (in `repository.rs`) instead of the domain one.
|
|
|
|
- [ ] **Step 4: Compile check**
|
|
```bash
|
|
cargo check --workspace 2>&1 | head -30
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
```bash
|
|
git commit -m "refactor(domain): remove AP delivery fields from RemoteActor domain model"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Change `ActivityPubRepository` port params from `url::Url` to `&str`
|
|
|
|
The domain port currently requires callers to construct `url::Url` — an external library type — before calling methods. Use `&str` at the port boundary; let the adapter parse.
|
|
|
|
**Files:**
|
|
- Modify: `crates/domain/src/ports.rs` (`ActivityPubRepository` trait)
|
|
- Modify: `crates/adapters/postgres/src/activitypub.rs`
|
|
- Modify: `crates/application/src/services/federation_event.rs`
|
|
- Modify: `crates/adapters/activitypub/src/handler.rs`
|
|
|
|
- [ ] **Step 1: Change method signatures in `ActivityPubRepository`**
|
|
|
|
In `ports.rs`, change:
|
|
```rust
|
|
async fn find_remote_actor_id(&self, actor_ap_url: &url::Url) -> Result<Option<UserId>, DomainError>;
|
|
async fn intern_remote_actor(&self, actor_ap_url: &url::Url) -> Result<UserId, DomainError>;
|
|
async fn accept_note(&self, ap_id: &url::Url, ..., in_reply_to: Option<&url::Url>) -> Result<(), DomainError>;
|
|
async fn apply_note_update(&self, ap_id: &url::Url, ...) -> Result<(), DomainError>;
|
|
async fn retract_note(&self, ap_id: &url::Url) -> Result<(), DomainError>;
|
|
async fn retract_actor_notes(&self, actor_ap_url: &url::Url) -> Result<(), DomainError>;
|
|
async fn get_thought_ap_id(&self, thought_id: &ThoughtId) -> Result<Option<String>, DomainError>;
|
|
async fn get_actor_ap_urls(&self, user_id: &UserId) -> Result<Option<ActorApUrls>, DomainError>;
|
|
```
|
|
|
|
To:
|
|
```rust
|
|
async fn find_remote_actor_id(&self, actor_ap_url: &str) -> Result<Option<UserId>, DomainError>;
|
|
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError>;
|
|
async fn accept_note(&self, ap_id: &str, ..., in_reply_to: Option<&str>) -> Result<(), DomainError>;
|
|
async fn apply_note_update(&self, ap_id: &str, ...) -> Result<(), DomainError>;
|
|
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError>;
|
|
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError>;
|
|
// get_thought_ap_id and get_actor_ap_urls already use &ThoughtId/&UserId — no change needed
|
|
```
|
|
|
|
- [ ] **Step 2: Update `PgActivityPubRepository` — parse URL internally**
|
|
|
|
In `postgres/src/activitypub.rs`, each method now receives `&str` and uses it directly in the SQL bind (URLs are stored as TEXT). Remove the `.as_str()` calls.
|
|
|
|
- [ ] **Step 3: Update callers — remove URL construction**
|
|
|
|
In `federation_event.rs` and `handler.rs`, remove `url::Url::parse(...)` before calling repo methods; pass the `&str` directly:
|
|
|
|
```rust
|
|
// Before
|
|
let actor_url = url::Url::parse(actor_ap_url)?;
|
|
let author_id = self.ap_repo.intern_remote_actor(&actor_url).await?;
|
|
|
|
// After
|
|
let author_id = self.ap_repo.intern_remote_actor(actor_ap_url).await?;
|
|
```
|
|
|
|
- [ ] **Step 4: Compile check**
|
|
```bash
|
|
cargo check --workspace 2>&1 | head -20
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
```bash
|
|
git commit -m "refactor(ports): ActivityPubRepository takes &str instead of &url::Url — infra type stays in adapter"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
**Spec coverage check:**
|
|
|
|
| Issue | Task |
|
|
|---|---|
|
|
| AP fields in domain models (User, Thought) | Task 1 (new lookup methods) + Task 2 (field removal) |
|
|
| DomainEvent contains AP events | Task 3 |
|
|
| FederationEventService mixes concerns | Task 3 (removes FetchX arms), Task 1 (removes ap_id reads) |
|
|
| Notification algebraic types | Task 6 |
|
|
| from_db_str silently defaults | Task 7 |
|
|
| CQRS UserRepository | Task 4 |
|
|
| CQRS FederationActionPort | Task 5 |
|
|
| Pass-through use cases | Task 8 |
|
|
| Duplicate actor resolution | Task 9 |
|
|
| RemoteActor AP field leakage | Task 10 |
|
|
| Port params url::Url → &str | Task 11 |
|
|
|
|
**Gaps found:**
|
|
- `FeedEntry` coupling (issue #10 from audit) — deferred. Requires FeedRepository changes and affects all feed handlers. Low risk to leave as-is; worth a separate plan.
|
|
|
|
**No placeholders found.** Each task has exact file paths and concrete code.
|
|
|
|
**Type consistency:** `ActorApUrls` introduced in Task 1 is used in Task 2. `NotificationKind` introduced in Task 6 is used throughout Task 6. All type names consistent.
|