27 KiB
ActivityPubRepository Port 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: Eliminate the activitypub → postgres dependency violation by extracting an ActivityPubRepository port into domain and implementing it in the postgres adapter.
Architecture: ActivityPubRepository (9 methods, federation vocabulary) is added to domain/src/ports.rs. PgActivityPubRepository in postgres/src/activitypub.rs implements it with all the SQL that currently lives in activitypub/src/handler.rs. ThoughtsObjectHandler drops its PgPool and receives Arc<dyn ActivityPubRepository> instead. The dependency chain becomes activitypub → domain only; postgres drops off the activitypub Cargo.toml entirely.
Tech Stack: Rust, sqlx 0.8, async-trait, existing domain value objects
File Map
Modify: crates/domain/src/ports.rs ← add OutboxEntry struct + ActivityPubRepository trait
Modify: crates/domain/src/testing.rs ← add TestStore impl ActivityPubRepository
Create: crates/adapters/postgres/src/activitypub.rs ← PgActivityPubRepository (all 9 methods)
Modify: crates/adapters/postgres/src/lib.rs ← pub mod activitypub
Modify: crates/adapters/activitypub/src/handler.rs ← replace PgPool with Arc<dyn ActivityPubRepository>
Modify: crates/adapters/activitypub/Cargo.toml ← remove postgres + sqlx deps
Modify: crates/presentation/src/lib.rs ← wire PgActivityPubRepository into ThoughtsObjectHandler
Task 1: Domain — OutboxEntry + ActivityPubRepository trait
Files:
-
Modify:
crates/domain/src/ports.rs -
Modify:
crates/domain/src/testing.rs -
Write the failing test — add to bottom of
crates/domain/src/testing.rsinside the existing#[cfg(any(test, feature = "test-helpers"))]scope:
#[cfg(test)]
mod ap_repo_tests {
use super::*;
use crate::models::thought::{Thought, Visibility};
use crate::value_objects::*;
#[tokio::test]
async fn test_store_outbox_returns_empty() {
let store = TestStore::default();
let result = store.outbox_entries_for_actor(&UserId::new()).await.unwrap();
assert!(result.is_empty());
}
#[tokio::test]
async fn test_store_intern_creates_placeholder() {
let store = TestStore::default();
let url = url::Url::parse("https://example.com/users/alice").unwrap();
let id1 = store.intern_remote_actor(&url).await.unwrap();
let id2 = store.intern_remote_actor(&url).await.unwrap();
assert_eq!(id1, id2, "intern must be idempotent");
}
}
-
Run:
cargo test -p domain— Expected: FAIL (ActivityPubRepository not defined). -
Add
OutboxEntryandActivityPubRepositorytocrates/domain/src/ports.rs— append after theSearchPorttrait:
/// A local thought ready for AP serialization, with the author's username
/// pre-joined so the handler can build AP URLs without a second query.
#[derive(Debug, Clone)]
pub struct OutboxEntry {
pub thought: crate::models::thought::Thought,
pub author_username: Username,
}
#[async_trait]
pub trait ActivityPubRepository: Send + Sync {
// ── Outbox (local → remote) ──────────────────────────────────────
/// All public local thoughts for this actor. Used for outbox totals
/// and full-collection delivery.
async fn outbox_entries_for_actor(
&self,
user_id: &UserId,
) -> Result<Vec<OutboxEntry>, DomainError>;
/// Cursor page of public local thoughts, newest-first, stopping before
/// `before`. Used for OrderedCollectionPage responses.
async fn outbox_page_for_actor(
&self,
user_id: &UserId,
before: Option<chrono::DateTime<chrono::Utc>>,
limit: usize,
) -> Result<Vec<OutboxEntry>, DomainError>;
// ── Remote actor resolution ──────────────────────────────────────
/// Find the local UserId for a remote actor by its AP URL.
async fn find_remote_actor_id(
&self,
actor_ap_url: &url::Url,
) -> Result<Option<UserId>, DomainError>;
/// Ensure a remote actor placeholder exists; create one if absent.
/// Idempotent — safe to call multiple times with the same URL.
async fn intern_remote_actor(
&self,
actor_ap_url: &url::Url,
) -> Result<UserId, DomainError>;
// ── Inbox processing (remote → local) ───────────────────────────
/// Persist an incoming remote Note. Idempotent on ap_id.
async fn accept_note(
&self,
ap_id: &url::Url,
author_id: &UserId,
content: &str,
published: chrono::DateTime<chrono::Utc>,
sensitive: bool,
content_warning: Option<String>,
) -> Result<(), DomainError>;
/// Apply an Update to a previously accepted remote Note.
async fn apply_note_update(
&self,
ap_id: &url::Url,
new_content: &str,
) -> Result<(), DomainError>;
/// Remove a specific remote Note (Delete activity). Only touches
/// remotely-originated thoughts.
async fn retract_note(&self, ap_id: &url::Url) -> Result<(), DomainError>;
/// Remove all Notes from a remote actor (actor-level Delete/Tombstone).
async fn retract_actor_notes(
&self,
actor_ap_url: &url::Url,
) -> Result<(), DomainError>;
// ── Node-level stats ─────────────────────────────────────────────
/// Total locally-authored thought count for NodeInfo responses.
async fn count_local_notes(&self) -> Result<u64, DomainError>;
}
The imports already present in ports.rs cover DomainError, UserId, Username, async_trait. The url::Url and chrono::DateTime types need to be in scope — add these use statements at the top of ports.rs if not already present:
use chrono::{DateTime, Utc};
use url::Url;
Note: url and chrono are already in domain/Cargo.toml. No dep changes needed.
- Add
TestStore impl ActivityPubRepositoryincrates/domain/src/testing.rs— append afterimpl SearchPort for TestStore:
#[async_trait] impl ActivityPubRepository for TestStore {
async fn outbox_entries_for_actor(&self, _uid: &UserId) -> Result<Vec<crate::ports::OutboxEntry>, DomainError> {
Ok(vec![])
}
async fn outbox_page_for_actor(&self, _uid: &UserId, _before: Option<chrono::DateTime<chrono::Utc>>, _limit: usize) -> Result<Vec<crate::ports::OutboxEntry>, DomainError> {
Ok(vec![])
}
async fn find_remote_actor_id(&self, actor_ap_url: &url::Url) -> Result<Option<UserId>, DomainError> {
let url = actor_ap_url.to_string();
Ok(self.users.lock().unwrap().iter().find(|u| u.ap_id.as_deref() == Some(&url)).map(|u| u.id.clone()))
}
async fn intern_remote_actor(&self, actor_ap_url: &url::Url) -> Result<UserId, DomainError> {
if let Some(uid) = self.find_remote_actor_id(actor_ap_url).await? {
return Ok(uid);
}
let uid = UserId::new();
let handle = actor_ap_url.path().trim_start_matches('/').replace('/', "_");
let user = crate::models::user::User {
id: uid.clone(),
username: Username::from_trusted(handle.clone()),
email: Email::from_trusted(format!("{}@remote", uid)),
password_hash: PasswordHash("".into()),
display_name: None, bio: None, avatar_url: None, header_url: None,
custom_css: None, local: false,
ap_id: Some(actor_ap_url.to_string()),
inbox_url: None, public_key: None, private_key: None,
created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(),
};
self.users.lock().unwrap().push(user);
Ok(uid)
}
async fn accept_note(&self, _ap_id: &url::Url, _author_id: &UserId, _content: &str, _published: chrono::DateTime<chrono::Utc>, _sensitive: bool, _content_warning: Option<String>) -> Result<(), DomainError> {
Ok(())
}
async fn apply_note_update(&self, _ap_id: &url::Url, _new_content: &str) -> Result<(), DomainError> { Ok(()) }
async fn retract_note(&self, _ap_id: &url::Url) -> Result<(), DomainError> { Ok(()) }
async fn retract_actor_notes(&self, _actor_ap_url: &url::Url) -> Result<(), DomainError> { Ok(()) }
async fn count_local_notes(&self) -> Result<u64, DomainError> {
Ok(self.thoughts.lock().unwrap().iter().filter(|t| t.local).count() as u64)
}
}
-
Run:
cargo test -p domain— Expected: all tests pass including 2 new ap_repo tests. -
Commit:
git add crates/domain/
git commit -m "feat(domain): ActivityPubRepository port with federation vocabulary"
Task 2: Postgres — PgActivityPubRepository
Files:
-
Create:
crates/adapters/postgres/src/activitypub.rs -
Modify:
crates/adapters/postgres/src/lib.rs -
Write integration tests at the bottom of the new
crates/adapters/postgres/src/activitypub.rs(create the file with tests first):
#[cfg(test)]
mod tests {
use super::*;
use domain::ports::ActivityPubRepository;
#[sqlx::test(migrations = "./migrations")]
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
let url = url::Url::parse("https://mastodon.social/users/alice").unwrap();
let id1 = repo.intern_remote_actor(&url).await.unwrap();
let id2 = repo.intern_remote_actor(&url).await.unwrap();
assert_eq!(id1, id2);
}
#[sqlx::test(migrations = "./migrations")]
async fn accept_and_retract_note(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
let actor_url = url::Url::parse("https://remote.example/users/bob").unwrap();
let ap_id = url::Url::parse("https://remote.example/notes/1").unwrap();
let author = repo.intern_remote_actor(&actor_url).await.unwrap();
repo.accept_note(&ap_id, &author, "hello from remote", chrono::Utc::now(), false, None)
.await.unwrap();
repo.retract_note(&ap_id).await.unwrap();
}
#[sqlx::test(migrations = "./migrations")]
async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
assert_eq!(repo.count_local_notes().await.unwrap(), 0);
}
}
-
Run:
cargo test -p postgres activitypub— Expected: FAIL (module does not exist). -
Write
crates/adapters/postgres/src/activitypub.rs:
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use url::Url;
use domain::{errors::DomainError, ports::{ActivityPubRepository, OutboxEntry}, value_objects::{Content, ThoughtId, UserId, Username}};
use domain::models::thought::{Thought, Visibility};
pub struct PgActivityPubRepository { pool: PgPool }
impl PgActivityPubRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
#[async_trait]
impl ActivityPubRepository for PgActivityPubRepository {
async fn outbox_entries_for_actor(&self, user_id: &UserId) -> Result<Vec<OutboxEntry>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row { id: uuid::Uuid, user_id: uuid::Uuid, content: String, created_at: DateTime<Utc>, in_reply_to_id: Option<uuid::Uuid>, content_warning: Option<String>, sensitive: bool, username: String, updated_at: Option<DateTime<Utc>> }
sqlx::query_as::<_, Row>(
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
FROM thoughts t JOIN users u ON u.id=t.user_id
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
ORDER BY t.created_at DESC"
)
.bind(user_id.as_uuid())
.fetch_all(&self.pool).await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|rows| rows.into_iter().map(|r| OutboxEntry {
thought: Thought {
id: ThoughtId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id),
content: Content::new_remote(r.content), in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
in_reply_to_url: None, ap_id: None, visibility: Visibility::Public,
content_warning: r.content_warning, sensitive: r.sensitive, local: true,
created_at: r.created_at, updated_at: r.updated_at,
},
author_username: Username::from_trusted(r.username),
}).collect())
}
async fn outbox_page_for_actor(&self, user_id: &UserId, before: Option<DateTime<Utc>>, limit: usize) -> Result<Vec<OutboxEntry>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row { id: uuid::Uuid, user_id: uuid::Uuid, content: String, created_at: DateTime<Utc>, in_reply_to_id: Option<uuid::Uuid>, content_warning: Option<String>, sensitive: bool, username: String, updated_at: Option<DateTime<Utc>> }
let rows = if let Some(before) = before {
sqlx::query_as::<_, Row>(
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
FROM thoughts t JOIN users u ON u.id=t.user_id
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public' AND t.created_at < $2
ORDER BY t.created_at DESC LIMIT $3"
).bind(user_id.as_uuid()).bind(before).bind(limit as i64).fetch_all(&self.pool).await
} else {
sqlx::query_as::<_, Row>(
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
FROM thoughts t JOIN users u ON u.id=t.user_id
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
ORDER BY t.created_at DESC LIMIT $2"
).bind(user_id.as_uuid()).bind(limit as i64).fetch_all(&self.pool).await
}.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(rows.into_iter().map(|r| OutboxEntry {
thought: Thought {
id: ThoughtId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id),
content: Content::new_remote(r.content), in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
in_reply_to_url: None, ap_id: None, visibility: Visibility::Public,
content_warning: r.content_warning, sensitive: r.sensitive, local: true,
created_at: r.created_at, updated_at: r.updated_at,
},
author_username: Username::from_trusted(r.username),
}).collect())
}
async fn find_remote_actor_id(&self, actor_ap_url: &Url) -> Result<Option<UserId>, DomainError> {
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1")
.bind(actor_ap_url.as_str())
.fetch_optional(&self.pool).await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|o| o.map(UserId::from_uuid))
}
async fn intern_remote_actor(&self, actor_ap_url: &Url) -> Result<UserId, DomainError> {
// Fast path
if let Some(id) = self.find_remote_actor_id(actor_ap_url).await? {
return Ok(id);
}
let new_id = uuid::Uuid::new_v4();
let handle = actor_ap_url.path().trim_start_matches('/').replace('/', "_");
sqlx::query(
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING"
)
.bind(new_id).bind(&handle).bind(format!("{}@remote", new_id)).bind(actor_ap_url.as_str())
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
// Re-fetch to get whichever id won the race
self.find_remote_actor_id(actor_ap_url).await?
.ok_or_else(|| DomainError::Internal("intern_remote_actor: insert succeeded but row not found".into()))
}
async fn accept_note(&self, ap_id: &Url, author_id: &UserId, content: &str, published: DateTime<Utc>, sensitive: bool, content_warning: Option<String>) -> Result<(), DomainError> {
let capped: String = content.chars().take(500).collect();
sqlx::query(
"INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at)
VALUES($1,$2,$3,$4,'public',$5,false,$6,$7) ON CONFLICT(ap_id) DO NOTHING"
)
.bind(uuid::Uuid::new_v4()).bind(author_id.as_uuid()).bind(&capped)
.bind(ap_id.as_str()).bind(sensitive).bind(content_warning).bind(published)
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
}
async fn apply_note_update(&self, ap_id: &Url, new_content: &str) -> Result<(), DomainError> {
let capped: String = new_content.chars().take(500).collect();
sqlx::query("UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false")
.bind(ap_id.as_str()).bind(&capped)
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
}
async fn retract_note(&self, ap_id: &Url) -> Result<(), DomainError> {
sqlx::query("DELETE FROM thoughts WHERE ap_id=$1 AND local=false")
.bind(ap_id.as_str())
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
}
async fn retract_actor_notes(&self, actor_ap_url: &Url) -> Result<(), DomainError> {
sqlx::query(
"DELETE FROM thoughts WHERE local=false AND user_id=(SELECT id FROM users WHERE ap_id=$1)"
)
.bind(actor_ap_url.as_str())
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
}
async fn count_local_notes(&self) -> Result<u64, DomainError> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE local=true")
.fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(n as u64)
}
}
-
Add
pub mod activitypub;tocrates/adapters/postgres/src/lib.rs— append alongside the other module declarations. -
Run:
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test -p postgres activitypubExpected: 3 tests pass. -
Commit:
git add crates/adapters/postgres/src/activitypub.rs crates/adapters/postgres/src/lib.rs
git commit -m "feat(postgres): PgActivityPubRepository implementing ActivityPubRepository port"
Task 3: activitypub adapter — use the port, drop postgres dep
Files:
-
Modify:
crates/adapters/activitypub/src/handler.rs -
Modify:
crates/adapters/activitypub/Cargo.toml -
Rewrite
crates/adapters/activitypub/src/handler.rs:
use std::sync::Arc;
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use url::Url;
use activitypub_base::ApObjectHandler;
use domain::ports::ActivityPubRepository;
use domain::value_objects::UserId;
use crate::note::ThoughtNote;
use crate::urls::ThoughtsUrls;
pub struct ThoughtsObjectHandler {
repo: Arc<dyn ActivityPubRepository>,
urls: ThoughtsUrls,
}
impl ThoughtsObjectHandler {
pub fn new(repo: Arc<dyn ActivityPubRepository>, base_url: &str) -> Self {
Self { repo, urls: ThoughtsUrls::new(base_url) }
}
}
#[async_trait]
impl ApObjectHandler for ThoughtsObjectHandler {
async fn get_local_objects_for_user(
&self,
user_id: uuid::Uuid,
) -> Result<Vec<(Url, serde_json::Value)>> {
let uid = UserId::from_uuid(user_id);
let entries = self.repo.outbox_entries_for_actor(&uid).await
.map_err(|e| anyhow!("{e}"))?;
entries.into_iter().map(|e| {
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
let actor_url = self.urls.user_url(e.author_username.as_str());
let followers = self.urls.user_followers(e.author_username.as_str());
let in_reply_to = e.thought.in_reply_to_id.map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public(
note_url.clone(), actor_url,
e.thought.content.as_str().to_owned(),
e.thought.created_at, in_reply_to,
e.thought.sensitive, e.thought.content_warning, followers,
);
Ok((note_url, serde_json::to_value(¬e)?))
}).collect()
}
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
before: Option<DateTime<Utc>>,
limit: usize,
) -> Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
let uid = UserId::from_uuid(user_id);
let entries = self.repo.outbox_page_for_actor(&uid, before, limit).await
.map_err(|e| anyhow!("{e}"))?;
entries.into_iter().map(|e| {
let created_at = e.thought.created_at;
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
let actor_url = self.urls.user_url(e.author_username.as_str());
let followers = self.urls.user_followers(e.author_username.as_str());
let in_reply_to = e.thought.in_reply_to_id.map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public(
note_url.clone(), actor_url,
e.thought.content.as_str().to_owned(),
created_at, in_reply_to,
e.thought.sensitive, e.thought.content_warning, followers,
);
Ok((note_url, serde_json::to_value(¬e)?, created_at))
}).collect()
}
async fn on_create(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> Result<()> {
let note: ThoughtNote = serde_json::from_value(object)?;
let author_id = self.repo.intern_remote_actor(actor_url).await
.map_err(|e| anyhow!("{e}"))?;
self.repo.accept_note(
ap_id, &author_id,
¬e.content,
note.published,
note.sensitive,
note.summary,
).await.map_err(|e| anyhow!("{e}"))
}
async fn on_update(
&self,
ap_id: &Url,
_actor_url: &Url,
object: serde_json::Value,
) -> Result<()> {
let note: ThoughtNote = serde_json::from_value(object)?;
self.repo.apply_note_update(ap_id, ¬e.content).await
.map_err(|e| anyhow!("{e}"))
}
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
self.repo.retract_note(ap_id).await.map_err(|e| anyhow!("{e}"))
}
async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> {
self.repo.retract_actor_notes(actor_url).await.map_err(|e| anyhow!("{e}"))
}
async fn count_local_posts(&self) -> Result<u64> {
self.repo.count_local_notes().await.map_err(|e| anyhow!("{e}"))
}
}
- Rewrite
crates/adapters/activitypub/Cargo.toml— removepostgresandsqlx:
[package]
name = "activitypub"
version = "0.1.0"
edition = "2021"
[dependencies]
activitypub-base = { workspace = true }
activitypub_federation = "0.7.0-beta.11"
domain = { workspace = true }
url = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
-
Run:
cargo check -p activitypubExpected: no errors. If there are unused import warnings forsqlxorPgPool— those are now gone, so the check should be clean. -
Run full test suite:
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspaceExpected: all 67 tests pass (handler.rs has no unit tests of its own, but the workspace test suite must stay green). -
Commit:
git add crates/adapters/activitypub/
git commit -m "refactor(activitypub): ThoughtsObjectHandler uses ActivityPubRepository port, drops postgres dep"
Task 4: Presentation — wire PgActivityPubRepository
Files:
- Modify:
crates/presentation/src/lib.rs
The current build_state in src/lib.rs calls ThoughtsObjectHandler::new(pool.clone(), &base_url). After Task 3, the signature changed to ThoughtsObjectHandler::new(repo: Arc<dyn ActivityPubRepository>, base_url: &str).
- Update the import and wiring in
crates/presentation/src/lib.rs:
Find the existing import line:
use activitypub::ThoughtsObjectHandler;
Add alongside it:
use postgres::activitypub::PgActivityPubRepository;
Find the existing call:
std::sync::Arc::new(ThoughtsObjectHandler::new(pool.clone(), &base_url)),
Replace with:
std::sync::Arc::new(ThoughtsObjectHandler::new(
std::sync::Arc::new(PgActivityPubRepository::new(pool.clone())),
&base_url,
)),
-
Run:
cargo build -p presentationExpected: clean build, no errors. -
Run full test suite:
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3
Expected: all tests pass.
- Verify dependency is gone:
cargo tree -p activitypub | grep postgres
Expected: no output — activitypub no longer depends on postgres.
- Commit:
git add crates/presentation/src/lib.rs
git commit -m "fix: wire PgActivityPubRepository into ThoughtsObjectHandler — closes activitypub→postgres violation"
Self-Review
Spec coverage:
- ✅
OutboxEntrystruct withthought: Thought+author_username: Username - ✅
ActivityPubRepositorytrait indomain/src/ports.rs— 9 methods with federation vocabulary - ✅
TestStore impl ActivityPubRepository— idempotentintern_remote_actor, empty stubs for others - ✅ 2 domain unit tests covering idempotency and empty outbox
- ✅
PgActivityPubRepositoryinpostgres/src/activitypub.rs— all 9 methods - ✅ 3 postgres integration tests
- ✅
ThoughtsObjectHandlerdropsPgPool, receivesArc<dyn ActivityPubRepository> - ✅
activitypub/Cargo.tomlremovespostgresandsqlxdeps - ✅ Presentation wires
PgActivityPubRepository→ThoughtsObjectHandler - ✅
cargo treeverification confirms violation is resolved
Placeholder scan: None.
Type consistency:
OutboxEntrydefined indomain/src/ports.rs, imported asdomain::ports::OutboxEntryin postgres — consistentThoughtsObjectHandler::new(repo: Arc<dyn ActivityPubRepository>, base_url: &str)— matches presentation wiringPgActivityPubRepository::new(pool: PgPool)— matches presentation wiring- All 9 method signatures identical between trait definition (Task 1) and impl (Task 2) and handler calls (Task 3)