From 6fd9a76e68f5e67c15f0915f2cebf892f881a0c5 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 14 May 2026 01:08:13 +0200 Subject: [PATCH] docs: v2 architecture rewrite design spec --- .../specs/2026-05-14-v2-rewrite-design.md | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-14-v2-rewrite-design.md diff --git a/docs/superpowers/specs/2026-05-14-v2-rewrite-design.md b/docs/superpowers/specs/2026-05-14-v2-rewrite-design.md new file mode 100644 index 0000000..8ca9553 --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-v2-rewrite-design.md @@ -0,0 +1,285 @@ +# 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.