Files
thoughts/docs/superpowers/specs/2026-05-14-v2-rewrite-design.md

11 KiB
Raw Blame History

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<dyn Port> 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<String>  (None for remote)

Thought       — ThoughtId, UserId, Content (≤128 chars local / unlimited remote),
                in_reply_to: Option<ThoughtId | RemoteUrl>, ap_id: Url,
                visibility: Public|Followers|Unlisted|Direct,
                content_warning: Option<String>, 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 (18)
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

-- 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

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<dyn Port> — no NATS coupling inside them. Worker main.rs wires everything together.