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