# Thoughts v2 — Architecture Rewrite Design ## Context Thoughts is a federated social web service currently running on a monolithic axum + Sea-ORM backend with no domain layer, no traits, and tightly coupled persistence. v2 is a full rewrite targeting: - Hexagonal architecture (ports & adapters, zero leakage between layers) - Full bidirectional ActivityPub federation (Mastodon-compatible Fediverse citizen) - sqlx with raw SQL — no ORM - Postgres only (for now), but no coupling to any concrete adapter - Crate structure mirroring movies-diary (the reference implementation) - Production data must survive cutover via additive migrations --- ## Crate Structure ``` crates/ domain/ # entities, value objects, ports (traits), domain events application/ # use cases (commands + queries), no framework deps api-types/ # request/response DTOs, shared serializable types presentation/ # axum handlers, routes, extractors, state, openapi worker/ # event consumer loop, dispatches to event handlers adapters/ postgres/ # sqlx impls of all repos + migrations/ postgres-search/ # SearchPort via pg_trgm / tsvector postgres-federation/ # federation-specific queries (known actors, etc.) activitypub-base/ # copied from movies-diary — signing, WebFinger, NodeInfo activitypub/ # thoughts-specific AP objects (Note, Person) + activity handlers auth/ # JWT AuthService impl nats/ # EventPublisher + EventConsumer via NATS event-payload/ # serializable event envelope types (NATS wire format) event-publisher/ # event routing — domain events → NATS subjects ``` **Dependency rule:** `domain` has zero external deps. `application` depends only on `domain`. All adapters depend on `domain` traits only — never on each other. `presentation` and `worker` wire concrete adapters into `Arc` and inject via state. `presentation` never imports from `postgres` directly. --- ## Domain Model ### Entities & Value Objects ``` User — UserId, Username, Email, PasswordHash, DisplayName, Bio, AvatarUrl, HeaderUrl, local: bool, ap_id: Url, public_key: String, private_key: Option (None for remote) Thought — ThoughtId, UserId, Content (≤128 chars local / unlimited remote), in_reply_to: Option, ap_id: Url, visibility: Public|Followers|Unlisted|Direct, content_warning: Option, sensitive: bool, local: bool Like — LikeId, UserId, ThoughtId, ap_id: Url Boost — BoostId, UserId, ThoughtId, ap_id: Url Follow — FollowerId, FollowingId, state: Pending|Accepted|Rejected, ap_id: Url Block — BlockerId, BlockedId Tag — TagId, name ApiKey — ApiKeyId, UserId, key_hash, name TopFriend — UserId, FriendId, position (1–8) RemoteActor — url, handle, display_name, inbox_url, shared_inbox_url, public_key ``` ### Ports (traits in domain, implemented by adapters) `UserRepository`, `ThoughtRepository`, `LikeRepository`, `BoostRepository`, `FollowRepository`, `BlockRepository`, `TagRepository`, `ApiKeyRepository`, `TopFriendRepository`, `RemoteActorRepository`, `AuthService`, `PasswordHasher`, `EventPublisher`, `EventConsumer`, `SearchPort`, `SearchCommand` ### Domain Events Published after mutations, consumed by worker for federation and side-effects: `ThoughtCreated`, `ThoughtDeleted`, `ThoughtUpdated`, `LikeAdded`, `LikeRemoved`, `BoostAdded`, `BoostRemoved`, `FollowRequested`, `FollowAccepted`, `FollowRejected`, `Unfollowed`, `UserBlocked` --- ## Application Layer (Use Cases) Each use case lives in `application/src/use_cases/` and receives only `&dyn Port` references — no framework types, no sqlx, no axum. Fully testable with mock impls. **Commands** (mutate state, publish domain event): ``` register, login create_thought, delete_thought, edit_thought create_reply, delete_reply like_thought, unlike_thought boost_thought, unboost_thought follow_user, unfollow_user, accept_follow, reject_follow block_user, unblock_user update_profile, update_top_friends create_api_key, delete_api_key handle_inbox ← processes incoming AP activities from remote instances ``` **Queries** (read-only, no events): ``` get_thought, get_thread ← thought + its reply tree get_home_feed ← thoughts from followed users (local + remote) get_public_feed ← all local public thoughts get_user_feed ← one user's public thoughts get_profile, get_top_friends get_followers, get_following list_api_keys search get_by_tag get_notifications ``` --- ## Federation & ActivityPub `activitypub-base/` (copied verbatim from movies-diary) handles: HTTP signatures, WebFinger, NodeInfo, generic actor/inbox/outbox/followers HTTP handlers, remote actor fetching. `activitypub/` wires `activitypub-base` to the thoughts domain. ### Outbound (worker: domain event → AP activity → remote inboxes) | Domain Event | AP Activity | Destination | |------------------|---------------------|--------------------------| | ThoughtCreated | Create(Note) | followers' inboxes | | ThoughtDeleted | Delete(Note) | followers' inboxes | | ThoughtUpdated | Update(Note) | followers' inboxes | | LikeAdded | Like | thought author's inbox | | LikeRemoved | Undo(Like) | thought author's inbox | | BoostAdded | Announce | followers' inboxes | | BoostRemoved | Undo(Announce) | followers' inboxes | | FollowRequested | Follow | target's inbox | | FollowAccepted | Accept(Follow) | requester's inbox | | FollowRejected | Reject(Follow) | requester's inbox | | Unfollowed | Undo(Follow) | target's inbox | | UserBlocked | Block | blocked user's inbox | ### Inbound (`handle_inbox` use case) | Incoming Activity | Use Case invoked | |-------------------|----------------------------| | Create(Note) | create_thought (remote) | | Delete | delete_thought (remote) | | Update(Note) | edit_thought (remote) | | Like | like_thought (remote) | | Undo(Like) | unlike_thought (remote) | | Announce | boost_thought (remote) | | Undo(Announce) | unboost_thought (remote) | | Follow | follow_user → auto-accept (public accounts) / pending (locked accounts) | | Accept(Follow) | accept_follow | | Reject(Follow) | reject_follow | | Undo(Follow) | unfollow_user | | Block | block_user (remote) | ### AP Endpoints (in presentation/) ``` GET /.well-known/webfinger GET /.well-known/nodeinfo GET /nodeinfo/2.0 GET /users/:username ← Actor object GET /users/:username/inbox POST /users/:username/inbox ← receives remote activities GET /users/:username/outbox GET /users/:username/followers GET /users/:username/following ``` --- ## Database Schema & Migration Strategy ### Remote thought caching `likes` and `boosts` reference `thought_id UUID REFERENCES thoughts(id)`. When a local user likes or boosts a remote thought, the remote Note is first fetched and cached as a row in `thoughts` with `local = false`. This keeps referential integrity and allows rendering liked/boosted remote content without additional AP lookups. ### Migration approach sqlx `migrations/` in `adapters/postgres/`. First migration recreates existing schema in sqlx format (matching production exactly, preserving all UUIDs). Subsequent migrations are additive only — no destructive changes. ### Additive changes to existing tables ```sql -- users: federation ALTER TABLE users ADD COLUMN ap_id TEXT UNIQUE; ALTER TABLE users ADD COLUMN inbox_url TEXT; ALTER TABLE users ADD COLUMN public_key TEXT; ALTER TABLE users ADD COLUMN private_key TEXT; -- NULL for remote users ALTER TABLE users ADD COLUMN local BOOLEAN NOT NULL DEFAULT true; -- thoughts: replies + AP + visibility ALTER TABLE thoughts ADD COLUMN in_reply_to_id UUID REFERENCES thoughts(id); ALTER TABLE thoughts ADD COLUMN in_reply_to_url TEXT; -- remote parent ALTER TABLE thoughts ADD COLUMN ap_id TEXT UNIQUE; ALTER TABLE thoughts ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public'; ALTER TABLE thoughts ADD COLUMN content_warning TEXT; ALTER TABLE thoughts ADD COLUMN sensitive BOOLEAN NOT NULL DEFAULT false; ALTER TABLE thoughts ADD COLUMN local BOOLEAN NOT NULL DEFAULT true; ALTER TABLE thoughts ADD COLUMN updated_at TIMESTAMPTZ; -- follows: pending state + AP id ALTER TABLE follows ADD COLUMN state TEXT NOT NULL DEFAULT 'accepted'; ALTER TABLE follows ADD COLUMN ap_id TEXT; ALTER TABLE follows ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); ``` ### New tables ```sql CREATE TABLE likes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id), thought_id UUID NOT NULL REFERENCES thoughts(id), ap_id TEXT UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (user_id, thought_id) ); CREATE TABLE boosts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id), thought_id UUID NOT NULL REFERENCES thoughts(id), ap_id TEXT UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (user_id, thought_id) ); CREATE TABLE blocks ( blocker_id UUID NOT NULL REFERENCES users(id), blocked_id UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (blocker_id, blocked_id) ); CREATE TABLE remote_actors ( url TEXT PRIMARY KEY, handle TEXT NOT NULL, display_name TEXT, inbox_url TEXT NOT NULL, shared_inbox_url TEXT, public_key TEXT NOT NULL, last_fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id), type TEXT NOT NULL, -- 'like','boost','follow','mention','reply' from_user_id UUID REFERENCES users(id), thought_id UUID REFERENCES thoughts(id), read BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` --- ## Event System & Worker ### event-payload/ Serializable wire types for NATS. Mirror of domain events with all fields as primitives (UUIDs as strings). `serde::Serialize/Deserialize`. No domain dependency. ### event-publisher/ Receives `DomainEvent`, serializes to event-payload, routes to NATS subject (e.g. `thoughts.created`, `likes.added`). Implements domain's `EventPublisher` trait. ### nats/ Wraps `async-nats`. Implements `EventPublisher` (publish to subject) and `EventConsumer` (subscribe, yields `EventEnvelope` stream with ack/nack handles). ### worker/ (binary) ``` EventConsumer::consume() → deserialize EventEnvelope → match event type → dispatch to EventHandler impl → ack on success, nack on failure (NATS redelivers) Handlers: FederationHandler ← domain events → AP activities → remote inboxes NotificationHandler ← writes notifications on like/boost/follow/mention/reply SearchIndexHandler ← indexes/removes documents on create/delete ``` Handlers are plain structs taking `Arc` — no NATS coupling inside them. Worker `main.rs` wires everything together.