docs: remote actor connections (followers/following) design spec
This commit is contained in:
213
docs/superpowers/specs/2026-05-15-actor-connections-design.md
Normal file
213
docs/superpowers/specs/2026-05-15-actor-connections-design.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# Remote Actor Connections (Followers/Following) Design
|
||||
|
||||
Display a remote actor's followers and following lists in the thoughts UI, with worker-backed caching and concurrent AP profile resolution.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. User opens the Followers or Following tab on a remote actor profile
|
||||
2. Frontend calls `GET /federation/actors/{handle}/followers-list?page=1`
|
||||
3. Backend returns cached data immediately (may be empty on first visit)
|
||||
4. If cache is empty OR older than 1 hour: publish `FetchActorConnections` event fire-and-forget
|
||||
5. Worker receives event → fetches remote collection page → concurrently resolves each actor URL to a profile → stores results
|
||||
6. Next visit / tab re-open shows populated data
|
||||
|
||||
## Domain Changes
|
||||
|
||||
### New models (`domain/src/models/`)
|
||||
|
||||
**`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" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`actor_connection_summary.rs`**:
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActorConnectionSummary {
|
||||
pub url: String, // AP URL of the connected actor
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### New `DomainEvent` variant (`domain/src/events.rs`)
|
||||
|
||||
```rust
|
||||
FetchActorConnections {
|
||||
actor_ap_url: String,
|
||||
collection_url: String,
|
||||
connection_type: String, // "followers" | "following"
|
||||
page: u32,
|
||||
},
|
||||
```
|
||||
|
||||
### New port (`domain/src/ports.rs`)
|
||||
|
||||
```rust
|
||||
pub trait RemoteActorConnectionRepository: Send + Sync {
|
||||
async fn upsert_connections(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
connection_type: &str,
|
||||
page: u32,
|
||||
actors: &[ActorConnectionSummary],
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
async fn list_connections(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
connection_type: &str,
|
||||
page: u32,
|
||||
) -> Result<Vec<ActorConnectionSummary>, DomainError>;
|
||||
|
||||
async fn connection_page_age(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
connection_type: &str,
|
||||
page: u32,
|
||||
) -> Result<Option<chrono::DateTime<chrono::Utc>>, DomainError>;
|
||||
}
|
||||
```
|
||||
|
||||
### New `FederationActionPort` method
|
||||
|
||||
```rust
|
||||
async fn resolve_actor_profiles(
|
||||
&self,
|
||||
urls: Vec<String>,
|
||||
) -> Vec<ActorConnectionSummary>;
|
||||
```
|
||||
|
||||
Returns only successful resolutions. Per-actor timeout: 5 seconds. Concurrent. No error propagation — failures are silently skipped (warn logged).
|
||||
|
||||
## Storage
|
||||
|
||||
### Migration: `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);
|
||||
```
|
||||
|
||||
### `PgRemoteActorConnectionRepository`
|
||||
|
||||
- `upsert_connections`: `INSERT ... ON CONFLICT 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()`
|
||||
- `list_connections`: `SELECT * WHERE actor_url=$1 AND connection_type=$2 AND page=$3 ORDER BY connected_handle`
|
||||
- `connection_page_age`: `SELECT MAX(fetched_at) WHERE actor_url=$1 AND connection_type=$2 AND page=$3`
|
||||
|
||||
## activitypub-base: `resolve_actor_profiles`
|
||||
|
||||
`ActivityPubService` implements `FederationActionPort::resolve_actor_profiles`:
|
||||
|
||||
1. For each URL: spawn `tokio::time::timeout(5s, fetch_actor_profile(url))`
|
||||
2. `fetch_actor_profile`: `GET {url}` with `Accept: application/activity+json` → parse `preferred_username`, `name`, `icon.url`, `id`
|
||||
3. Collect `Ok` results → return as `Vec<ActorConnectionSummary>`
|
||||
4. Failed/timed-out actors: `tracing::warn!` and skip
|
||||
|
||||
## event-payload
|
||||
|
||||
Add `FetchActorConnections { actor_ap_url, collection_url, connection_type, page }` to `EventPayload` — subject: `"federation.fetch_actor_connections"`. Add to `From<&DomainEvent>`, `TryFrom<EventPayload>`, and uniqueness test.
|
||||
|
||||
## Worker
|
||||
|
||||
`FederationEventService` gains `remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>`.
|
||||
|
||||
Handler for `FetchActorConnections { actor_ap_url, collection_url, connection_type, page }`:
|
||||
|
||||
1. Fetch `collection_url` (as AP JSON) → extract `orderedItems` array as Vec of URL strings
|
||||
2. If empty: return Ok(()) — nothing to store
|
||||
3. `federation_action.resolve_actor_profiles(urls).await` — concurrent, partial success OK
|
||||
4. `remote_actor_connections.upsert_connections(actor_ap_url, connection_type, page, &results).await`
|
||||
5. Log: `tracing::info!(count = results.len(), "actor connections cached")`
|
||||
|
||||
Wire `remote_actor_connections` in `worker/src/factory.rs`.
|
||||
|
||||
## AppState + Bootstrap
|
||||
|
||||
Add `remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>` to `AppState`. Wire `PgRemoteActorConnectionRepository` in `bootstrap/src/factory.rs`.
|
||||
|
||||
## REST Endpoints
|
||||
|
||||
**`GET /federation/actors/{handle}/followers-list?page=1`**
|
||||
|
||||
```
|
||||
1. lookup_actor(handle) → get actor_ap_url + followers_url
|
||||
2. list_connections(actor_ap_url, "followers", page) → cached items
|
||||
3. connection_page_age(...) → if None or > 1 hour: publish FetchActorConnections (fire-and-forget)
|
||||
4. Return { items: [...], page, has_more: items.len() == PAGE_SIZE }
|
||||
```
|
||||
|
||||
`PAGE_SIZE = 20`. `has_more` tells the frontend whether to show a "next" button.
|
||||
|
||||
**`GET /federation/actors/{handle}/following-list?page=1`** — identical, uses `following_url` and `"following"`.
|
||||
|
||||
Response item shape (reuses `RemoteActorResponse` minus `bio`/`banner`/`attachment`/`outbox_url`):
|
||||
```json
|
||||
{ "handle": "...", "displayName": "...", "avatarUrl": "...", "url": "..." }
|
||||
```
|
||||
|
||||
Define as a new `ActorConnectionResponse` in api-types.
|
||||
|
||||
Mount both routes in `routes.rs`. Add new handler file `federation_actors.rs` (already exists — add to it).
|
||||
|
||||
## Frontend
|
||||
|
||||
### `lib/api.ts`
|
||||
|
||||
```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 ConnectionPageSchema = z.object({
|
||||
items: z.array(ActorConnectionSchema),
|
||||
page: z.number(),
|
||||
hasMore: z.boolean(),
|
||||
});
|
||||
|
||||
export const getActorFollowers = (handle, page, token) =>
|
||||
apiFetch(`/federation/actors/${encodeURIComponent(handle)}/followers-list?page=${page}`, {}, ConnectionPageSchema, token);
|
||||
|
||||
export const getActorFollowing = (handle, page, token) =>
|
||||
apiFetch(`/federation/actors/${encodeURIComponent(handle)}/following-list?page=${page}`, {}, ConnectionPageSchema, token);
|
||||
```
|
||||
|
||||
### `RemoteUserProfile` changes
|
||||
|
||||
Replace the plain "Followers / Following" link section with two client-side tabs. Each tab:
|
||||
- Shows a list of `RemoteUserCard` components (reuse existing)
|
||||
- "Load more" button if `hasMore`
|
||||
- Empty state: "Loading — check back soon."
|
||||
- Tab is lazy: only fetches when first opened (not on profile load)
|
||||
|
||||
Use the existing `RemoteUserCard` component — it already handles follow button and linking.
|
||||
|
||||
### `remote-user-profile.tsx` note
|
||||
|
||||
The component is already a client component (`"use client"`), so React state for tab selection and paginated data works fine. Each tab fetches via `getActorFollowers`/`getActorFollowing` when first activated.
|
||||
Reference in New Issue
Block a user