286 lines
11 KiB
Markdown
286 lines
11 KiB
Markdown
# 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 (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.
|