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

286 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 — JSON REST only, no HTML rendering (client is Next.js)
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
```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<dyn Port>` — no NATS coupling inside them. Worker `main.rs` wires everything together.