docs: v2 architecture rewrite design spec
This commit is contained in:
285
docs/superpowers/specs/2026-05-14-v2-rewrite-design.md
Normal file
285
docs/superpowers/specs/2026-05-14-v2-rewrite-design.md
Normal file
@@ -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<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 (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<dyn Port>` — no NATS coupling inside them. Worker `main.rs` wires everything together.
|
||||
Reference in New Issue
Block a user