1206 lines
36 KiB
Markdown
1206 lines
36 KiB
Markdown
# Remote Actor Connections 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:** Show a remote actor's followers and following as browseable lists within the thoughts UI, backed by a worker cache with concurrent AP profile resolution.
|
|
|
|
**Architecture:** New domain models (`ConnectionType`, `ActorConnectionSummary`) + new port (`RemoteActorConnectionRepository`) + new `FederationActionPort` methods. REST endpoints return cached data and fire a `FetchActorConnections` event fire-and-forget. Worker fetches the AP collection, concurrently resolves each actor URL to a profile (5s timeout per actor, partial failures silently skipped), and upserts results. Frontend adds Followers/Following tabs to `RemoteUserProfile` using existing `RemoteUserCard`.
|
|
|
|
**Tech Stack:** Rust (axum, sqlx, tokio, reqwest), NATS/JetStream, Next.js 15, TypeScript, Zod.
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
| Action | Path | Change |
|
|
|--------|------|--------|
|
|
| Create | `crates/domain/src/models/connection_type.rs` | `ConnectionType` enum |
|
|
| Create | `crates/domain/src/models/actor_connection_summary.rs` | `ActorConnectionSummary` struct |
|
|
| Modify | `crates/domain/src/models/mod.rs` | expose new modules |
|
|
| Modify | `crates/domain/src/events.rs` | `FetchActorConnections` variant |
|
|
| Modify | `crates/domain/src/ports.rs` | `RemoteActorConnectionRepository` port; 2 new `FederationActionPort` methods |
|
|
| Modify | `crates/domain/src/testing.rs` | stubs + test |
|
|
| Create | `crates/adapters/postgres/migrations/006_remote_actor_connections.sql` | new table |
|
|
| Create | `crates/adapters/postgres/src/remote_actor_connections.rs` | postgres impl |
|
|
| Modify | `crates/adapters/postgres/src/lib.rs` | expose module, export type |
|
|
| Modify | `crates/adapters/activitypub-base/src/service.rs` | impl 2 new port methods |
|
|
| Modify | `crates/adapters/event-payload/src/lib.rs` | `FetchActorConnections` variant |
|
|
| Modify | `crates/application/src/services/federation_event.rs` | new dep + handler |
|
|
| Modify | `crates/worker/src/factory.rs` | wire `remote_actor_connections` |
|
|
| Modify | `crates/api-types/src/responses.rs` | `ActorConnectionResponse` |
|
|
| Modify | `crates/presentation/src/state.rs` | add `remote_actor_connections` field |
|
|
| Modify | `crates/bootstrap/src/factory.rs` | wire new repo |
|
|
| Modify | `crates/presentation/src/handlers/federation_actors.rs` | 2 new handlers |
|
|
| Modify | `crates/presentation/src/handlers/*.rs` (tests) | add `remote_actor_connections` to `make_state()` |
|
|
| Modify | `crates/presentation/src/routes.rs` | mount 2 new routes |
|
|
| Modify | `thoughts-frontend/lib/api.ts` | new schema + 2 fetch functions |
|
|
| Modify | `thoughts-frontend/components/remote-user-profile.tsx` | replace links with tabs |
|
|
|
|
---
|
|
|
|
## Task 1: Domain — models, port, event, stubs
|
|
|
|
**Files:**
|
|
- Create: `crates/domain/src/models/connection_type.rs`
|
|
- Create: `crates/domain/src/models/actor_connection_summary.rs`
|
|
- Modify: `crates/domain/src/models/mod.rs`
|
|
- Modify: `crates/domain/src/events.rs`
|
|
- Modify: `crates/domain/src/ports.rs`
|
|
- Modify: `crates/domain/src/testing.rs`
|
|
|
|
- [ ] **Step 1: Create `connection_type.rs`**
|
|
|
|
```rust
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ConnectionType {
|
|
Followers,
|
|
Following,
|
|
}
|
|
|
|
impl ConnectionType {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Followers => "followers",
|
|
Self::Following => "following",
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create `actor_connection_summary.rs`**
|
|
|
|
```rust
|
|
#[derive(Debug, Clone)]
|
|
pub struct ActorConnectionSummary {
|
|
pub url: String,
|
|
pub handle: String,
|
|
pub display_name: Option<String>,
|
|
pub avatar_url: Option<String>,
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Register in `models/mod.rs`**
|
|
|
|
Add:
|
|
```rust
|
|
pub mod actor_connection_summary;
|
|
pub mod connection_type;
|
|
```
|
|
|
|
- [ ] **Step 4: Add `FetchActorConnections` to `DomainEvent`**
|
|
|
|
Read `crates/domain/src/events.rs`. Add before the closing brace:
|
|
```rust
|
|
FetchActorConnections {
|
|
actor_ap_url: String,
|
|
collection_url: String,
|
|
connection_type: String,
|
|
page: u32,
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 5: Write failing domain test**
|
|
|
|
At the bottom of `crates/domain/src/testing.rs`, in the `federation_port_tests` module, add:
|
|
```rust
|
|
#[tokio::test]
|
|
async fn test_store_resolve_actor_profiles_returns_empty() {
|
|
let store = TestStore::default();
|
|
let result = store.resolve_actor_profiles(vec!["https://example.com/users/alice".into()]).await;
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_store_fetch_collection_urls_returns_empty() {
|
|
let store = TestStore::default();
|
|
let urls = store.fetch_actor_urls_from_collection("https://example.com/users/alice/followers").await.unwrap();
|
|
assert!(urls.is_empty());
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Run to confirm compile failure**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: compile error — new port methods and `RemoteActorConnectionRepository` not defined.
|
|
|
|
- [ ] **Step 7: Add `RemoteActorConnectionRepository` to `ports.rs`**
|
|
|
|
Read `crates/domain/src/ports.rs`. Add after `RemoteActorRepository`:
|
|
|
|
```rust
|
|
#[async_trait]
|
|
pub trait RemoteActorConnectionRepository: Send + Sync {
|
|
async fn upsert_connections(
|
|
&self,
|
|
actor_url: &str,
|
|
connection_type: &str,
|
|
page: u32,
|
|
actors: &[crate::models::actor_connection_summary::ActorConnectionSummary],
|
|
) -> Result<(), DomainError>;
|
|
|
|
async fn list_connections(
|
|
&self,
|
|
actor_url: &str,
|
|
connection_type: &str,
|
|
page: u32,
|
|
) -> Result<Vec<crate::models::actor_connection_summary::ActorConnectionSummary>, DomainError>;
|
|
|
|
async fn connection_page_age(
|
|
&self,
|
|
actor_url: &str,
|
|
connection_type: &str,
|
|
page: u32,
|
|
) -> Result<Option<chrono::DateTime<chrono::Utc>>, DomainError>;
|
|
}
|
|
```
|
|
|
|
Then in `FederationActionPort`, add two new methods:
|
|
```rust
|
|
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>;
|
|
```
|
|
|
|
- [ ] **Step 8: Add stubs to `TestStore`**
|
|
|
|
In `crates/domain/src/testing.rs`, add after the existing `impl FederationActionPort for TestStore` block:
|
|
|
|
```rust
|
|
#[async_trait]
|
|
impl RemoteActorConnectionRepository for TestStore {
|
|
async fn upsert_connections(
|
|
&self,
|
|
_actor_url: &str,
|
|
_connection_type: &str,
|
|
_page: u32,
|
|
_actors: &[crate::models::actor_connection_summary::ActorConnectionSummary],
|
|
) -> Result<(), DomainError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn list_connections(
|
|
&self,
|
|
_actor_url: &str,
|
|
_connection_type: &str,
|
|
_page: u32,
|
|
) -> Result<Vec<crate::models::actor_connection_summary::ActorConnectionSummary>, DomainError> {
|
|
Ok(vec![])
|
|
}
|
|
|
|
async fn connection_page_age(
|
|
&self,
|
|
_actor_url: &str,
|
|
_connection_type: &str,
|
|
_page: u32,
|
|
) -> Result<Option<chrono::DateTime<chrono::Utc>>, DomainError> {
|
|
Ok(None)
|
|
}
|
|
}
|
|
```
|
|
|
|
Inside `impl FederationActionPort for TestStore`, add the two new methods:
|
|
```rust
|
|
async fn fetch_actor_urls_from_collection(
|
|
&self,
|
|
_collection_url: &str,
|
|
) -> Result<Vec<String>, DomainError> {
|
|
Ok(vec![])
|
|
}
|
|
|
|
async fn resolve_actor_profiles(
|
|
&self,
|
|
_urls: Vec<String>,
|
|
) -> Vec<crate::models::actor_connection_summary::ActorConnectionSummary> {
|
|
vec![]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 9: Run tests to confirm pass**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: all tests pass.
|
|
|
|
- [ ] **Step 10: Full compile check**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo check -p domain 2>&1 | tail -5
|
|
```
|
|
|
|
- [ ] **Step 11: Commit**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts
|
|
git add crates/domain/src/models/connection_type.rs \
|
|
crates/domain/src/models/actor_connection_summary.rs \
|
|
crates/domain/src/models/mod.rs \
|
|
crates/domain/src/events.rs \
|
|
crates/domain/src/ports.rs \
|
|
crates/domain/src/testing.rs
|
|
git commit -m "feat(domain): ActorConnectionSummary, ConnectionType, RemoteActorConnectionRepository, FetchActorConnections event"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: PostgreSQL adapter — migration + repository
|
|
|
|
**Files:**
|
|
- Create: `crates/adapters/postgres/migrations/006_remote_actor_connections.sql`
|
|
- Create: `crates/adapters/postgres/src/remote_actor_connections.rs`
|
|
- Modify: `crates/adapters/postgres/src/lib.rs`
|
|
|
|
- [ ] **Step 1: Create migration**
|
|
|
|
Create `crates/adapters/postgres/migrations/006_remote_actor_connections.sql`:
|
|
|
|
```sql
|
|
CREATE TABLE remote_actor_connections (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
actor_url TEXT NOT NULL,
|
|
connection_type TEXT NOT NULL,
|
|
page INT NOT NULL,
|
|
connected_actor_url TEXT NOT NULL,
|
|
connected_handle TEXT NOT NULL,
|
|
connected_display_name TEXT,
|
|
connected_avatar_url TEXT,
|
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE(actor_url, connection_type, page, connected_actor_url)
|
|
);
|
|
CREATE INDEX ON remote_actor_connections(actor_url, connection_type, page, fetched_at);
|
|
```
|
|
|
|
- [ ] **Step 2: Create `remote_actor_connections.rs`**
|
|
|
|
Create `crates/adapters/postgres/src/remote_actor_connections.rs`:
|
|
|
|
```rust
|
|
use async_trait::async_trait;
|
|
use domain::{
|
|
errors::DomainError,
|
|
models::actor_connection_summary::ActorConnectionSummary,
|
|
ports::RemoteActorConnectionRepository,
|
|
};
|
|
use sqlx::PgPool;
|
|
|
|
pub struct PgRemoteActorConnectionRepository {
|
|
pool: PgPool,
|
|
}
|
|
|
|
impl PgRemoteActorConnectionRepository {
|
|
pub fn new(pool: PgPool) -> Self {
|
|
Self { pool }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl RemoteActorConnectionRepository for PgRemoteActorConnectionRepository {
|
|
async fn upsert_connections(
|
|
&self,
|
|
actor_url: &str,
|
|
connection_type: &str,
|
|
page: u32,
|
|
actors: &[ActorConnectionSummary],
|
|
) -> Result<(), DomainError> {
|
|
for actor in actors {
|
|
sqlx::query(
|
|
"INSERT INTO remote_actor_connections
|
|
(actor_url, connection_type, page, connected_actor_url,
|
|
connected_handle, connected_display_name, connected_avatar_url, fetched_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
|
ON CONFLICT(actor_url, connection_type, page, connected_actor_url)
|
|
DO UPDATE SET
|
|
connected_handle = EXCLUDED.connected_handle,
|
|
connected_display_name = EXCLUDED.connected_display_name,
|
|
connected_avatar_url = EXCLUDED.connected_avatar_url,
|
|
fetched_at = NOW()",
|
|
)
|
|
.bind(actor_url)
|
|
.bind(connection_type)
|
|
.bind(page as i32)
|
|
.bind(&actor.url)
|
|
.bind(&actor.handle)
|
|
.bind(&actor.display_name)
|
|
.bind(&actor.avatar_url)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn list_connections(
|
|
&self,
|
|
actor_url: &str,
|
|
connection_type: &str,
|
|
page: u32,
|
|
) -> Result<Vec<ActorConnectionSummary>, DomainError> {
|
|
#[derive(sqlx::FromRow)]
|
|
struct Row {
|
|
connected_actor_url: String,
|
|
connected_handle: String,
|
|
connected_display_name: Option<String>,
|
|
connected_avatar_url: Option<String>,
|
|
}
|
|
let rows = sqlx::query_as::<_, Row>(
|
|
"SELECT connected_actor_url, connected_handle, connected_display_name, connected_avatar_url
|
|
FROM remote_actor_connections
|
|
WHERE actor_url = $1 AND connection_type = $2 AND page = $3
|
|
ORDER BY connected_handle",
|
|
)
|
|
.bind(actor_url)
|
|
.bind(connection_type)
|
|
.bind(page as i32)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|r| ActorConnectionSummary {
|
|
url: r.connected_actor_url,
|
|
handle: r.connected_handle,
|
|
display_name: r.connected_display_name,
|
|
avatar_url: r.connected_avatar_url,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
async fn connection_page_age(
|
|
&self,
|
|
actor_url: &str,
|
|
connection_type: &str,
|
|
page: u32,
|
|
) -> Result<Option<chrono::DateTime<chrono::Utc>>, DomainError> {
|
|
let row: Option<(Option<chrono::DateTime<chrono::Utc>>,)> = sqlx::query_as(
|
|
"SELECT MAX(fetched_at) FROM remote_actor_connections
|
|
WHERE actor_url = $1 AND connection_type = $2 AND page = $3",
|
|
)
|
|
.bind(actor_url)
|
|
.bind(connection_type)
|
|
.bind(page as i32)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
|
|
Ok(row.and_then(|(ts,)| ts))
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Expose in `postgres/src/lib.rs`**
|
|
|
|
Read `crates/adapters/postgres/src/lib.rs`. Add:
|
|
```rust
|
|
pub mod remote_actor_connections;
|
|
```
|
|
|
|
- [ ] **Step 4: Compile check**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo check -p postgres 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts
|
|
git add crates/adapters/postgres/migrations/006_remote_actor_connections.sql \
|
|
crates/adapters/postgres/src/remote_actor_connections.rs \
|
|
crates/adapters/postgres/src/lib.rs
|
|
git commit -m "feat(postgres): remote_actor_connections table + PgRemoteActorConnectionRepository"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: activitypub-base — implement `fetch_actor_urls_from_collection` + `resolve_actor_profiles`
|
|
|
|
**Files:**
|
|
- Modify: `crates/adapters/activitypub-base/src/service.rs`
|
|
|
|
- [ ] **Step 1: Confirm compile failure**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5
|
|
```
|
|
|
|
Expected: error — `fetch_actor_urls_from_collection` and `resolve_actor_profiles` not implemented.
|
|
|
|
- [ ] **Step 2: Implement both methods in the `FederationActionPort` impl block**
|
|
|
|
Read the file. At the bottom of `impl domain::ports::FederationActionPort for ActivityPubService`, after `fetch_outbox_page`, add:
|
|
|
|
```rust
|
|
async fn fetch_actor_urls_from_collection(
|
|
&self,
|
|
collection_url: &str,
|
|
) -> Result<Vec<String>, domain::errors::DomainError> {
|
|
let resp: serde_json::Value = reqwest::Client::new()
|
|
.get(collection_url)
|
|
.header("Accept", "application/activity+json, application/ld+json")
|
|
.send()
|
|
.await
|
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?
|
|
.json()
|
|
.await
|
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
|
|
|
|
let empty = vec![];
|
|
let items = resp["orderedItems"].as_array().unwrap_or(&empty);
|
|
Ok(items
|
|
.iter()
|
|
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
|
.collect())
|
|
}
|
|
|
|
async fn resolve_actor_profiles(
|
|
&self,
|
|
urls: Vec<String>,
|
|
) -> Vec<domain::models::actor_connection_summary::ActorConnectionSummary> {
|
|
use futures::future;
|
|
|
|
async fn fetch_one(
|
|
url: String,
|
|
) -> Option<domain::models::actor_connection_summary::ActorConnectionSummary> {
|
|
let resp: serde_json::Value = tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
reqwest::Client::new()
|
|
.get(&url)
|
|
.header("Accept", "application/activity+json")
|
|
.send(),
|
|
)
|
|
.await
|
|
.ok()?
|
|
.ok()?
|
|
.json()
|
|
.await
|
|
.ok()?;
|
|
|
|
let ap_url = resp["id"].as_str()?.to_string();
|
|
let preferred_username = resp["preferredUsername"].as_str().unwrap_or("").to_string();
|
|
let domain_str = url::Url::parse(&ap_url)
|
|
.ok()
|
|
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
|
.unwrap_or_default();
|
|
let handle = format!("{}@{}", preferred_username, domain_str);
|
|
let display_name = resp["name"].as_str().map(|s| s.to_string());
|
|
let avatar_url = resp["icon"]["url"].as_str().map(|s| s.to_string());
|
|
|
|
Some(domain::models::actor_connection_summary::ActorConnectionSummary {
|
|
url: ap_url,
|
|
handle,
|
|
display_name,
|
|
avatar_url,
|
|
})
|
|
}
|
|
|
|
let futs: Vec<_> = urls.into_iter().map(fetch_one).collect();
|
|
let results = future::join_all(futs).await;
|
|
|
|
results
|
|
.into_iter()
|
|
.filter_map(|r| {
|
|
if r.is_none() {
|
|
tracing::warn!("failed to resolve actor profile (timeout or parse error)");
|
|
}
|
|
r
|
|
})
|
|
.collect()
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Compile check**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5
|
|
```
|
|
|
|
- [ ] **Step 4: Run all tests**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5
|
|
```
|
|
|
|
Expected: all pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts
|
|
git add crates/adapters/activitypub-base/src/service.rs
|
|
git commit -m "feat(activitypub-base): impl fetch_actor_urls_from_collection + resolve_actor_profiles (concurrent, 5s timeout)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: event-payload — `FetchActorConnections`
|
|
|
|
**Files:**
|
|
- Modify: `crates/adapters/event-payload/src/lib.rs`
|
|
|
|
- [ ] **Step 1: Add variant to `EventPayload` enum**
|
|
|
|
Read the file. Add at the end of the enum:
|
|
```rust
|
|
FetchActorConnections {
|
|
actor_ap_url: String,
|
|
collection_url: String,
|
|
connection_type: String,
|
|
page: u32,
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 2: Add subject**
|
|
|
|
In `subject()`:
|
|
```rust
|
|
Self::FetchActorConnections { .. } => "federation.fetch_actor_connections",
|
|
```
|
|
|
|
- [ ] **Step 3: Add `From<&DomainEvent>` arm**
|
|
|
|
```rust
|
|
DomainEvent::FetchActorConnections {
|
|
actor_ap_url,
|
|
collection_url,
|
|
connection_type,
|
|
page,
|
|
} => Self::FetchActorConnections {
|
|
actor_ap_url: actor_ap_url.clone(),
|
|
collection_url: collection_url.clone(),
|
|
connection_type: connection_type.clone(),
|
|
page: *page,
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 4: Add `TryFrom<EventPayload>` arm**
|
|
|
|
```rust
|
|
EventPayload::FetchActorConnections {
|
|
actor_ap_url,
|
|
collection_url,
|
|
connection_type,
|
|
page,
|
|
} => DomainEvent::FetchActorConnections {
|
|
actor_ap_url,
|
|
collection_url,
|
|
connection_type,
|
|
page,
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 5: Add to uniqueness test sample array**
|
|
|
|
```rust
|
|
EventPayload::FetchActorConnections {
|
|
actor_ap_url: "https://mastodon.social/users/alice".into(),
|
|
collection_url: "https://mastodon.social/users/alice/followers".into(),
|
|
connection_type: "followers".into(),
|
|
page: 1,
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 6: Test**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo test -p event-payload 2>&1 | tail -5
|
|
```
|
|
|
|
Expected: all pass (uniqueness test includes new variant).
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts
|
|
git add crates/adapters/event-payload/src/lib.rs
|
|
git commit -m "feat(event-payload): FetchActorConnections event"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Worker — handle `FetchActorConnections` + wire repo
|
|
|
|
**Files:**
|
|
- Modify: `crates/application/src/services/federation_event.rs`
|
|
- Modify: `crates/worker/src/factory.rs`
|
|
|
|
- [ ] **Step 1: Add `remote_actor_connections` to `FederationEventService`**
|
|
|
|
Read `crates/application/src/services/federation_event.rs`. Add to the struct:
|
|
```rust
|
|
pub remote_actor_connections: Arc<dyn domain::ports::RemoteActorConnectionRepository>,
|
|
```
|
|
|
|
- [ ] **Step 2: Handle `FetchActorConnections` in `process()`**
|
|
|
|
Before the `_ => Ok(())` arm, add:
|
|
|
|
```rust
|
|
DomainEvent::FetchActorConnections {
|
|
actor_ap_url,
|
|
collection_url,
|
|
connection_type,
|
|
page,
|
|
} => {
|
|
let urls = match self
|
|
.federation_action
|
|
.fetch_actor_urls_from_collection(collection_url)
|
|
.await
|
|
{
|
|
Ok(u) => u,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
collection_url,
|
|
error = %e,
|
|
"failed to fetch actor connections collection"
|
|
);
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
if urls.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let summaries = self
|
|
.federation_action
|
|
.resolve_actor_profiles(urls)
|
|
.await;
|
|
|
|
if summaries.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
tracing::info!(
|
|
count = summaries.len(),
|
|
connection_type,
|
|
actor = actor_ap_url,
|
|
"caching actor connections"
|
|
);
|
|
|
|
self.remote_actor_connections
|
|
.upsert_connections(actor_ap_url, connection_type, *page, &summaries)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add test**
|
|
|
|
In the `#[cfg(test)]` block, add `remote_actor_connections: Arc::new(store.clone())` to the `svc()` helper, then add:
|
|
|
|
```rust
|
|
#[tokio::test]
|
|
async fn fetch_actor_connections_is_noop_when_collection_empty() {
|
|
let store = TestStore::default();
|
|
let spy = Arc::new(SpyPort::default());
|
|
svc(&store, spy.clone())
|
|
.process(&DomainEvent::FetchActorConnections {
|
|
actor_ap_url: "https://mastodon.social/users/alice".into(),
|
|
collection_url: "https://mastodon.social/users/alice/followers".into(),
|
|
connection_type: "followers".into(),
|
|
page: 1,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo test -p application 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: all pass.
|
|
|
|
- [ ] **Step 5: Wire `remote_actor_connections` in `worker/src/factory.rs`**
|
|
|
|
Read the file. Add import:
|
|
```rust
|
|
use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;
|
|
```
|
|
|
|
Add the repo:
|
|
```rust
|
|
let actor_connections = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()))
|
|
as Arc<dyn domain::ports::RemoteActorConnectionRepository>;
|
|
```
|
|
|
|
Add to `FederationEventService` construction:
|
|
```rust
|
|
remote_actor_connections: actor_connections,
|
|
```
|
|
|
|
- [ ] **Step 6: Compile check**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo check -p worker 2>&1 | tail -10
|
|
```
|
|
|
|
- [ ] **Step 7: Run all tests**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5
|
|
```
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts
|
|
git add crates/application/src/services/federation_event.rs \
|
|
crates/worker/src/factory.rs
|
|
git commit -m "feat(worker): handle FetchActorConnections — resolve and cache remote actor connections"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: AppState + bootstrap + REST endpoints
|
|
|
|
**Files:**
|
|
- Modify: `crates/presentation/src/state.rs`
|
|
- Modify: `crates/bootstrap/src/factory.rs`
|
|
- Modify: `crates/api-types/src/responses.rs`
|
|
- Modify: `crates/presentation/src/handlers/federation_actors.rs`
|
|
- Modify: `crates/presentation/src/handlers/` (test make_state() helpers)
|
|
- Modify: `crates/presentation/src/routes.rs`
|
|
|
|
- [ ] **Step 1: Add `remote_actor_connections` to `AppState`**
|
|
|
|
Read `crates/presentation/src/state.rs`. Add field:
|
|
```rust
|
|
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
|
|
```
|
|
|
|
`RemoteActorConnectionRepository` is in `domain::ports::*`, already imported.
|
|
|
|
- [ ] **Step 2: Wire in `bootstrap/src/factory.rs`**
|
|
|
|
Read the file. Add import:
|
|
```rust
|
|
use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;
|
|
```
|
|
|
|
Add to `AppState { ... }`:
|
|
```rust
|
|
remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
|
|
```
|
|
|
|
- [ ] **Step 3: Add `ActorConnectionResponse` to api-types**
|
|
|
|
Read `crates/api-types/src/responses.rs`. Add:
|
|
|
|
```rust
|
|
#[derive(Serialize, utoipa::ToSchema)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ActorConnectionResponse {
|
|
pub handle: String,
|
|
pub display_name: Option<String>,
|
|
pub avatar_url: Option<String>,
|
|
pub url: String,
|
|
}
|
|
|
|
#[derive(Serialize, utoipa::ToSchema)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ActorConnectionPageResponse {
|
|
pub items: Vec<ActorConnectionResponse>,
|
|
pub page: u32,
|
|
pub has_more: bool,
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Fix broken test `make_state()` helpers**
|
|
|
|
Find all handlers with `make_state()` that construct `AppState` — they will now be missing `remote_actor_connections`. Run:
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo test -p presentation 2>&1 | grep "missing field" | head -5
|
|
```
|
|
For each affected test module, add `remote_actor_connections: store.clone()` to the `AppState` construction.
|
|
|
|
- [ ] **Step 5: Add two new handlers to `federation_actors.rs`**
|
|
|
|
Read the file. Add imports at the top:
|
|
```rust
|
|
use api_types::responses::{ActorConnectionPageResponse, ActorConnectionResponse};
|
|
use domain::events::DomainEvent;
|
|
```
|
|
|
|
Add after `remote_actor_posts_handler`:
|
|
|
|
```rust
|
|
const CACHE_TTL_SECS: i64 = 3600;
|
|
|
|
pub async fn actor_followers_handler(
|
|
State(s): State<AppState>,
|
|
Path(handle): Path<String>,
|
|
Query(q): Query<PaginationQuery>,
|
|
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
|
|
actor_connections_handler(s, handle, "followers", q.page() as u32).await
|
|
}
|
|
|
|
pub async fn actor_following_handler(
|
|
State(s): State<AppState>,
|
|
Path(handle): Path<String>,
|
|
Query(q): Query<PaginationQuery>,
|
|
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
|
|
actor_connections_handler(s, handle, "following", q.page() as u32).await
|
|
}
|
|
|
|
async fn actor_connections_handler(
|
|
s: AppState,
|
|
handle: String,
|
|
connection_type: &str,
|
|
page: u32,
|
|
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
|
|
const PAGE_SIZE: usize = 20;
|
|
|
|
let actor = s.federation.lookup_actor(&handle).await?;
|
|
|
|
let collection_url = match connection_type {
|
|
"followers" => actor
|
|
.followers_url
|
|
.ok_or_else(|| ApiError::BadRequest("actor has no followers URL".into()))?,
|
|
_ => actor
|
|
.following_url
|
|
.ok_or_else(|| ApiError::BadRequest("actor has no following URL".into()))?,
|
|
};
|
|
|
|
let items = s
|
|
.remote_actor_connections
|
|
.list_connections(&actor.url, connection_type, page)
|
|
.await?;
|
|
|
|
// Fire fetch if cache is missing or stale
|
|
let stale = match s
|
|
.remote_actor_connections
|
|
.connection_page_age(&actor.url, connection_type, page)
|
|
.await?
|
|
{
|
|
None => true,
|
|
Some(age) => {
|
|
chrono::Utc::now()
|
|
.signed_duration_since(age)
|
|
.num_seconds()
|
|
> CACHE_TTL_SECS
|
|
}
|
|
};
|
|
|
|
if stale {
|
|
let _ = s
|
|
.events
|
|
.publish(&DomainEvent::FetchActorConnections {
|
|
actor_ap_url: actor.url.clone(),
|
|
collection_url,
|
|
connection_type: connection_type.to_string(),
|
|
page,
|
|
})
|
|
.await;
|
|
}
|
|
|
|
let has_more = items.len() >= PAGE_SIZE;
|
|
Ok(Json(ActorConnectionPageResponse {
|
|
items: items
|
|
.into_iter()
|
|
.map(|a| ActorConnectionResponse {
|
|
handle: a.handle,
|
|
display_name: a.display_name,
|
|
avatar_url: a.avatar_url,
|
|
url: a.url,
|
|
})
|
|
.collect(),
|
|
page,
|
|
has_more,
|
|
}))
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Mount routes**
|
|
|
|
Read `crates/presentation/src/routes.rs`. After the existing `/federation/actors/{handle}/posts` route, add:
|
|
|
|
```rust
|
|
.route(
|
|
"/federation/actors/{handle}/followers-list",
|
|
get(federation_actors::actor_followers_handler),
|
|
)
|
|
.route(
|
|
"/federation/actors/{handle}/following-list",
|
|
get(federation_actors::actor_following_handler),
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 7: Run all tests**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -5
|
|
```
|
|
|
|
Expected: all pass.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts
|
|
git add crates/presentation/src/state.rs \
|
|
crates/bootstrap/src/factory.rs \
|
|
crates/api-types/src/responses.rs \
|
|
crates/presentation/src/handlers/federation_actors.rs \
|
|
crates/presentation/src/routes.rs
|
|
# Also add any handler files with updated make_state()
|
|
git commit -m "feat(presentation): followers/following list endpoints for remote actors"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Frontend — API + tabs in `RemoteUserProfile`
|
|
|
|
**Files:**
|
|
- Modify: `thoughts-frontend/lib/api.ts`
|
|
- Modify: `thoughts-frontend/components/remote-user-profile.tsx`
|
|
|
|
- [ ] **Step 1: Add schema + fetch functions to `api.ts`**
|
|
|
|
Read the file. After `getActorFollowing`/`getActorFollowers` (or after `getRemoteActorPosts`), add:
|
|
|
|
```typescript
|
|
export const ActorConnectionSchema = z.object({
|
|
handle: z.string(),
|
|
displayName: z.string().nullable(),
|
|
avatarUrl: z.string().nullable(),
|
|
url: z.string(),
|
|
});
|
|
export type ActorConnection = z.infer<typeof ActorConnectionSchema>;
|
|
|
|
const ActorConnectionPageSchema = z.object({
|
|
items: z.array(ActorConnectionSchema),
|
|
page: z.number(),
|
|
hasMore: z.boolean(),
|
|
});
|
|
|
|
export const getActorFollowers = (
|
|
handle: string,
|
|
page: number,
|
|
token: string | null
|
|
) =>
|
|
apiFetch(
|
|
`/federation/actors/${encodeURIComponent(handle)}/followers-list?page=${page}`,
|
|
{},
|
|
ActorConnectionPageSchema,
|
|
token
|
|
);
|
|
|
|
export const getActorFollowing = (
|
|
handle: string,
|
|
page: number,
|
|
token: string | null
|
|
) =>
|
|
apiFetch(
|
|
`/federation/actors/${encodeURIComponent(handle)}/following-list?page=${page}`,
|
|
{},
|
|
ActorConnectionPageSchema,
|
|
token
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 2: Update `remote-user-profile.tsx`**
|
|
|
|
Read the full file. Replace the existing followers/following links section AND add tab state + lazy loading. The component is already `"use client"`.
|
|
|
|
Add imports at the top:
|
|
```typescript
|
|
import { getActorFollowers, getActorFollowing, ActorConnection } from "@/lib/api";
|
|
import { RemoteUserCard } from "@/components/remote-user-card";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
```
|
|
|
|
Add state inside the component (after existing state):
|
|
```typescript
|
|
type Tab = "posts" | "followers" | "following";
|
|
const [activeTab, setActiveTab] = useState<Tab>("posts");
|
|
const [followers, setFollowers] = useState<ActorConnection[]>([]);
|
|
const [following, setFollowing] = useState<ActorConnection[]>([]);
|
|
const [followersPage, setFollowersPage] = useState(1);
|
|
const [followingPage, setFollowingPage] = useState(1);
|
|
const [followersHasMore, setFollowersHasMore] = useState(false);
|
|
const [followingHasMore, setFollowingHasMore] = useState(false);
|
|
const [followersLoaded, setFollowersLoaded] = useState(false);
|
|
const [followingLoaded, setFollowingLoaded] = useState(false);
|
|
```
|
|
|
|
Add tab handlers:
|
|
```typescript
|
|
const loadFollowers = async (page: number) => {
|
|
const result = await getActorFollowers(actor.handle, page, token).catch(() => null);
|
|
if (!result) return;
|
|
setFollowers((prev) => page === 1 ? result.items : [...prev, ...result.items]);
|
|
setFollowersHasMore(result.hasMore);
|
|
setFollowersLoaded(true);
|
|
setFollowersPage(page);
|
|
};
|
|
|
|
const loadFollowing = async (page: number) => {
|
|
const result = await getActorFollowing(actor.handle, page, token).catch(() => null);
|
|
if (!result) return;
|
|
setFollowing((prev) => page === 1 ? result.items : [...prev, ...result.items]);
|
|
setFollowingHasMore(result.hasMore);
|
|
setFollowingLoaded(true);
|
|
setFollowingPage(page);
|
|
};
|
|
|
|
const handleTabChange = (tab: string) => {
|
|
setActiveTab(tab as Tab);
|
|
if (tab === "followers" && !followersLoaded) loadFollowers(1);
|
|
if (tab === "following" && !followingLoaded) loadFollowing(1);
|
|
};
|
|
```
|
|
|
|
Replace the posts section (`<div className="col-span-1 lg:col-span-3 space-y-4">...`) with:
|
|
|
|
```tsx
|
|
<div className="col-span-1 lg:col-span-3">
|
|
<Tabs defaultValue="posts" onValueChange={handleTabChange}>
|
|
<TabsList>
|
|
<TabsTrigger value="posts">Posts</TabsTrigger>
|
|
<TabsTrigger value="followers">Followers</TabsTrigger>
|
|
<TabsTrigger value="following">Following</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="posts" className="space-y-4 mt-4">
|
|
{initialPosts.length > 0 ? (
|
|
<ThoughtList
|
|
thoughts={initialPosts}
|
|
authorDetails={authorDetails}
|
|
currentUser={me}
|
|
/>
|
|
) : (
|
|
<Card className="flex items-center justify-center h-48">
|
|
<p className="text-center text-muted-foreground">
|
|
Posts are being fetched — check back soon.
|
|
</p>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="followers" className="mt-4">
|
|
{!followersLoaded ? (
|
|
<Card className="flex items-center justify-center h-48">
|
|
<p className="text-center text-muted-foreground">Loading followers…</p>
|
|
</Card>
|
|
) : followers.length === 0 ? (
|
|
<Card className="flex items-center justify-center h-48">
|
|
<p className="text-center text-muted-foreground">
|
|
No followers cached yet — check back soon.
|
|
</p>
|
|
</Card>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{followers.map((f) => (
|
|
<RemoteUserCard key={f.url} actor={f} />
|
|
))}
|
|
{followersHasMore && (
|
|
<button
|
|
onClick={() => loadFollowers(followersPage + 1)}
|
|
className="w-full text-sm text-muted-foreground hover:text-foreground py-2"
|
|
>
|
|
Load more
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="following" className="mt-4">
|
|
{!followingLoaded ? (
|
|
<Card className="flex items-center justify-center h-48">
|
|
<p className="text-center text-muted-foreground">Loading following…</p>
|
|
</Card>
|
|
) : following.length === 0 ? (
|
|
<Card className="flex items-center justify-center h-48">
|
|
<p className="text-center text-muted-foreground">
|
|
No following cached yet — check back soon.
|
|
</p>
|
|
</Card>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{following.map((f) => (
|
|
<RemoteUserCard key={f.url} actor={f} />
|
|
))}
|
|
{followingHasMore && (
|
|
<button
|
|
onClick={() => loadFollowing(followingPage + 1)}
|
|
className="w-full text-sm text-muted-foreground hover:text-foreground py-2"
|
|
>
|
|
Load more
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
```
|
|
|
|
Also remove the old `{(actor.followersUrl || actor.followingUrl) && ...}` plain links section from the sidebar — replaced by tabs.
|
|
|
|
- [ ] **Step 3: Type-check**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -10
|
|
```
|
|
|
|
Fix any type errors. Common issue: `RemoteUserCard` expects `RemoteActor` but we're passing `ActorConnection` — both have the same shape (`handle`, `displayName`, `avatarUrl`, `url`) so you may need a cast or to widen the prop type on `RemoteUserCard`.
|
|
|
|
If `RemoteUserCard` is typed as `actor: RemoteActor`, change its prop to `actor: { handle: string; displayName: string | null; avatarUrl: string | null; url: string }` or union type. Alternatively, cast: `actor={f as RemoteActor}`.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts
|
|
git add thoughts-frontend/lib/api.ts \
|
|
thoughts-frontend/components/remote-user-profile.tsx
|
|
git commit -m "feat(frontend): followers/following tabs on remote actor profile with lazy loading + pagination"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
**Spec coverage:**
|
|
- ✅ `ConnectionType` enum — Task 1
|
|
- ✅ `ActorConnectionSummary` model — Task 1
|
|
- ✅ `RemoteActorConnectionRepository` port — Task 1
|
|
- ✅ `fetch_actor_urls_from_collection` on `FederationActionPort` — Tasks 1 + 3
|
|
- ✅ `resolve_actor_profiles` on `FederationActionPort` (concurrent, 5s timeout, partial) — Tasks 1 + 3
|
|
- ✅ `FetchActorConnections` domain event — Task 1
|
|
- ✅ Migration + `PgRemoteActorConnectionRepository` — Task 2
|
|
- ✅ activitypub-base implements both new methods — Task 3
|
|
- ✅ event-payload wired — Task 4
|
|
- ✅ Worker handles event (fetch collection → resolve profiles → upsert) — Task 5
|
|
- ✅ 1-hour TTL cache logic in endpoint — Task 6
|
|
- ✅ `AppState` + bootstrap wired — Task 6
|
|
- ✅ `ActorConnectionResponse` + `ActorConnectionPageResponse` — Task 6
|
|
- ✅ Two REST endpoints + routes — Task 6
|
|
- ✅ Frontend: schema, fetch fns, tabs with lazy load + pagination — Task 7
|
|
- ✅ Failure handling: partial resolution, warn log, skip — Task 3
|
|
|
|
**Placeholder scan:** None found.
|
|
|
|
**Type consistency:**
|
|
- `ActorConnectionSummary.url` (domain) → `ActorConnectionResponse.url` (api-types) → `ActorConnection.url` (frontend schema) ✅
|
|
- `connection_type: &str` in port matches `connection_type: String` in event (converted via `.as_str()` when needed) ✅
|
|
- `page: u32` in port, event, endpoint, frontend ✅
|
|
- `RemoteUserCard` prop type — noted in Task 7 step 3 ✅
|