Files
thoughts/docs/superpowers/plans/2026-05-14-bootstrap-factory.md

432 lines
16 KiB
Markdown

# Bootstrap Factory Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extract the composition root out of `presentation` into a dedicated `bootstrap` crate with a `factory.rs` that builds all dependencies from config — so `presentation` becomes a pure HTTP library with no knowledge of concrete adapters.
**Architecture:** `crates/bootstrap/` is a new binary crate. It contains `config.rs` (reads env vars), `factory.rs` (creates all concrete `Arc<dyn Port>` adapters and returns `Infrastructure { state, fed_config }`), and a thin `main.rs`. `presentation` loses `[[bin]]`, `build_state`, and all concrete adapter imports — it only depends on `domain`, `application`, `api-types`, `axum`, `activitypub-base`, and UI/docs libs.
**Tech Stack:** existing Rust workspace, sqlx, async-nats, all existing adapter crates
---
## File Map
```
Create: crates/bootstrap/Cargo.toml ← binary crate, imports all concrete adapters
Create: crates/bootstrap/src/config.rs ← Config struct + from_env()
Create: crates/bootstrap/src/factory.rs ← build(config) → Infrastructure { state, fed_config }
Create: crates/bootstrap/src/main.rs ← thin: read config, call factory, serve
Modify: Cargo.toml (root) ← add "crates/bootstrap" to workspace members
Modify: crates/presentation/Cargo.toml ← remove [[bin]], remove all concrete adapter deps
Modify: crates/presentation/src/lib.rs ← remove build_state + NoOpEventPublisher + imports
Modify: crates/presentation/src/state.rs ← remove fed_config field
Delete: crates/presentation/src/main.rs ← binary moves to bootstrap
```
**Key design decision:** `fed_config` is removed from `AppState`. `factory::build()` returns `Infrastructure { state, fed_config }` separately. `main.rs` passes them independently to `router(&infra.fed_config).with_state(infra.state)`. This makes `AppState` pure `Arc<dyn Port>` with no infrastructure types.
---
### Task 1: Create bootstrap crate
**Files:**
- Create: `crates/bootstrap/Cargo.toml`
- Create: `crates/bootstrap/src/config.rs`
- Create: `crates/bootstrap/src/factory.rs`
- Create: `crates/bootstrap/src/main.rs`
- Modify: `Cargo.toml` (root)
- [ ] **Add `"crates/bootstrap"` to `[workspace] members`** in root `Cargo.toml`.
- [ ] **Create `crates/bootstrap/Cargo.toml`:**
```toml
[package]
name = "bootstrap"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "thoughts"
path = "src/main.rs"
[dependencies]
presentation = { workspace = true }
domain = { workspace = true }
postgres = { workspace = true }
postgres-search = { workspace = true }
postgres-federation = { workspace = true }
activitypub = { workspace = true }
activitypub-base = { workspace = true }
nats = { workspace = true }
auth = { workspace = true }
sqlx = { workspace = true }
async-nats = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tower-http = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
dotenvy = { workspace = true }
```
- [ ] **Create `crates/bootstrap/src/config.rs`:**
```rust
/// All configuration read from environment variables at startup.
pub struct Config {
pub database_url: String,
pub jwt_secret: String,
pub base_url: String,
pub nats_url: Option<String>,
pub port: u16,
pub allow_registration: bool,
/// true when RUST_ENV != "production" — enables AP debug mode
pub debug: bool,
}
impl Config {
pub fn from_env() -> Self {
dotenvy::dotenv().ok();
Self {
database_url: std::env::var("DATABASE_URL")
.expect("DATABASE_URL is required"),
jwt_secret: std::env::var("JWT_SECRET")
.expect("JWT_SECRET is required"),
base_url: std::env::var("BASE_URL")
.unwrap_or_else(|_| "http://localhost:3000".into()),
nats_url: std::env::var("NATS_URL").ok(),
port: std::env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(3000),
allow_registration: std::env::var("ALLOW_REGISTRATION")
.map(|v| v == "true")
.unwrap_or(true),
debug: std::env::var("RUST_ENV")
.map(|v| v != "production")
.unwrap_or(true),
}
}
}
```
- [ ] **Create `crates/bootstrap/src/factory.rs`:**
```rust
use std::sync::Arc;
use async_trait::async_trait;
use sqlx::PgPool;
use activitypub::ThoughtsObjectHandler;
use activitypub_base::{ApFederationConfig, FederationData};
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
use postgres::activitypub::PgActivityPubRepository;
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
use presentation::state::AppState;
use crate::config::Config;
/// Everything the binary needs to start serving: the axum state and the
/// federation config (used when building the router).
pub struct Infrastructure {
pub state: AppState,
pub fed_config: ApFederationConfig,
}
// ── No-op publisher (fallback when NATS is unavailable) ──────────────────────
struct NoOpEventPublisher;
#[async_trait]
impl EventPublisher for NoOpEventPublisher {
async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> { Ok(()) }
}
// ── Factory ───────────────────────────────────────────────────────────────────
pub async fn build(cfg: &Config) -> Infrastructure {
// 1. Database connection + migrations
let pool = PgPool::connect(&cfg.database_url)
.await
.expect("Failed to connect to database");
sqlx::migrate!("../adapters/postgres/migrations")
.run(&pool)
.await
.expect("Failed to run migrations");
tracing::info!("Database connected and migrations applied");
// 2. Event publisher — real NATS or no-op fallback
let event_publisher: Arc<dyn EventPublisher> = match &cfg.nats_url {
Some(url) => match async_nats::connect(url).await {
Ok(client) => {
tracing::info!("Connected to NATS at {url}");
Arc::new(nats::NatsEventPublisher::new(client))
}
Err(e) => {
tracing::warn!("NATS connect failed ({e}) — falling back to no-op publisher");
Arc::new(NoOpEventPublisher)
}
},
None => {
tracing::info!("NATS_URL not set — using no-op event publisher");
Arc::new(NoOpEventPublisher)
}
};
// 3. ActivityPub federation
let fed_data = FederationData::new(
Arc::new(PostgresFederationRepository::new(pool.clone())),
Arc::new(PostgresApUserRepository::new(pool.clone(), cfg.base_url.clone())),
Arc::new(ThoughtsObjectHandler::new(
Arc::new(PgActivityPubRepository::new(pool.clone())),
&cfg.base_url,
)),
cfg.base_url.clone(),
cfg.allow_registration,
"thoughts".to_string(),
None, // event_publisher wired separately via NATS
);
let fed_config = ApFederationConfig::new(fed_data, cfg.debug)
.await
.expect("Failed to build federation config");
// 4. Application state — all concrete repos injected as Arc<dyn Port>
let state = AppState {
users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())),
thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())),
likes: Arc::new(postgres::like::PgLikeRepository::new(pool.clone())),
boosts: Arc::new(postgres::boost::PgBoostRepository::new(pool.clone())),
follows: Arc::new(postgres::follow::PgFollowRepository::new(pool.clone())),
blocks: Arc::new(postgres::block::PgBlockRepository::new(pool.clone())),
tags: Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
api_keys: Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())),
top_friends: Arc::new(postgres::top_friend::PgTopFriendRepository::new(pool.clone())),
notifications: Arc::new(postgres::notification::PgNotificationRepository::new(pool.clone())),
remote_actors: Arc::new(postgres::remote_actor::PgRemoteActorRepository::new(pool.clone())),
feed: Arc::new(postgres::feed::PgFeedRepository::new(pool.clone())),
search: Arc::new(postgres_search::PgSearchRepository::new(pool.clone())),
auth: Arc::new(auth::JwtAuthService::new(cfg.jwt_secret.clone(), 86400 * 30)),
hasher: Arc::new(auth::Argon2PasswordHasher),
events: event_publisher,
};
Infrastructure { state, fed_config }
}
```
- [ ] **Create `crates/bootstrap/src/main.rs`:**
```rust
mod config;
mod factory;
use tower_http::cors::CorsLayer;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() {
let cfg = config::Config::from_env();
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let infra = factory::build(&cfg).await;
let app = presentation::routes::router(&infra.fed_config)
.with_state(infra.state)
.layer(CorsLayer::permissive());
let addr = format!("0.0.0.0:{}", cfg.port);
tracing::info!("Listening on {addr}");
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
```
- [ ] **Run:** `cargo check -p bootstrap` — Expected: no errors.
Note: `presentation` still has its old `[[bin]]` at this point — that's fine, both binaries exist temporarily.
- [ ] **Smoke test from bootstrap:**
```bash
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres \
JWT_SECRET=dev BASE_URL=http://localhost:3000 \
RUST_LOG=info cargo run --bin thoughts
```
Open a second terminal:
```bash
curl -s -X POST http://localhost:3000/auth/register \
-H 'content-type: application/json' \
-d '{"username":"bootstraptest","email":"boot@test.com","password":"pw"}' | jq .token
```
Expected: returns a JWT token.
- [ ] **Commit:**
```bash
git add Cargo.toml crates/bootstrap/
git commit -m "feat(bootstrap): composition root with Config + factory.rs"
```
---
### Task 2: Clean presentation — strip concrete deps, remove binary
**Files:**
- Modify: `crates/presentation/Cargo.toml`
- Modify: `crates/presentation/src/lib.rs`
- Modify: `crates/presentation/src/state.rs`
- Delete: `crates/presentation/src/main.rs`
- [ ] **Remove `[[bin]]` table and `src/main.rs` from `crates/presentation/Cargo.toml`:**
Delete these lines entirely:
```toml
[[bin]]
name = "thoughts"
path = "src/main.rs"
```
- [ ] **Strip concrete adapter deps from `crates/presentation/Cargo.toml`:**
Remove these lines:
```toml
postgres = { workspace = true }
postgres-search = { workspace = true }
postgres-federation = { workspace = true }
activitypub = { workspace = true }
nats = { workspace = true }
async-nats = { workspace = true }
sqlx = { workspace = true }
auth = { workspace = true }
dotenvy = { workspace = true }
tracing-subscriber = { workspace = true }
```
Keep these (they belong to the HTTP layer):
```toml
domain = { workspace = true }
application = { workspace = true }
api-types = { workspace = true }
axum = { workspace = true }
tower-http = { workspace = true }
tokio = { workspace = true, features = ["full"] }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
sha2 = "0.10"
hex = "0.4"
activitypub-base = { workspace = true }
activitypub_federation = "0.7.0-beta.11"
url = { workspace = true }
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] }
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
```
- [ ] **Rewrite `crates/presentation/src/lib.rs`** — remove `build_state`, `NoOpEventPublisher`, and all concrete imports. The file becomes purely module declarations:
```rust
pub mod errors;
pub mod extractors;
pub mod handlers;
pub mod openapi;
pub mod routes;
pub mod state;
```
- [ ] **Remove `fed_config` from `crates/presentation/src/state.rs`:**
The `AppState` struct currently has `pub fed_config: ApFederationConfig`. Remove that field and its import. The struct becomes:
```rust
use std::sync::Arc;
use domain::ports::*;
#[derive(Clone)]
pub struct AppState {
pub users: Arc<dyn UserRepository>,
pub thoughts: Arc<dyn ThoughtRepository>,
pub likes: Arc<dyn LikeRepository>,
pub boosts: Arc<dyn BoostRepository>,
pub follows: Arc<dyn FollowRepository>,
pub blocks: Arc<dyn BlockRepository>,
pub tags: Arc<dyn TagRepository>,
pub api_keys: Arc<dyn ApiKeyRepository>,
pub top_friends: Arc<dyn TopFriendRepository>,
pub notifications: Arc<dyn NotificationRepository>,
pub remote_actors: Arc<dyn RemoteActorRepository>,
pub feed: Arc<dyn FeedRepository>,
pub search: Arc<dyn SearchPort>,
pub auth: Arc<dyn AuthService>,
pub hasher: Arc<dyn PasswordHasher>,
pub events: Arc<dyn EventPublisher>,
}
```
- [ ] **Delete `crates/presentation/src/main.rs`:**
```bash
rm crates/presentation/src/main.rs
```
- [ ] **Run:** `cargo check -p presentation` — Expected: no errors.
- [ ] **Run:** `cargo check -p bootstrap` — Expected: no errors (bootstrap now owns the binary).
- [ ] **Verify only bootstrap knows about postgres:**
```bash
cargo tree -p presentation 2>/dev/null | grep -E "postgres|sqlx|nats|auth" | head -5 || echo "clean"
```
Expected: `clean` — no concrete adapter deps in presentation.
- [ ] **Run full test suite:**
```bash
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3
```
Expected: all tests pass.
- [ ] **Commit:**
```bash
git add crates/presentation/Cargo.toml \
crates/presentation/src/lib.rs \
crates/presentation/src/state.rs
git rm crates/presentation/src/main.rs
git commit -m "refactor(presentation): pure HTTP library — remove build_state, concrete adapter deps, and binary"
```
---
## Self-Review
**Spec coverage:**
-`bootstrap/config.rs` reads all env vars into typed `Config` struct (Task 1)
-`bootstrap/factory.rs` builds all `Arc<dyn Port>` adapters from `Config` (Task 1)
-`bootstrap/main.rs` is thin: read config → factory → serve (Task 1)
-`presentation` loses `[[bin]]`, `main.rs`, `build_state`, `NoOpEventPublisher` (Task 2)
-`presentation/Cargo.toml` no longer imports postgres, nats, auth, sqlx, etc. (Task 2)
-`AppState` has no `fed_config` field — pure `Arc<dyn Port>` (Task 2)
-`cargo tree -p presentation | grep postgres` returns nothing (Task 2)
**Placeholder scan:** None.
**Type consistency:**
- `factory::build(cfg: &Config) -> Infrastructure` — matches `main.rs` call
- `Infrastructure { state: AppState, fed_config: ApFederationConfig }``state` matches `routes::router().with_state(state)`, `fed_config` matches `routes::router(&infra.fed_config)`
- `AppState` without `fed_config``factory.rs` constructs it correctly (no `fed_config:` field)
- `sqlx::migrate!("../adapters/postgres/migrations")` in `factory.rs` — path is relative to `CARGO_MANIFEST_DIR` of `bootstrap` crate (`crates/bootstrap/`), resolves to `crates/adapters/postgres/migrations`
**Note on Dockerfile:** The existing `Dockerfile` references the `thoughts` binary. Since `bootstrap/Cargo.toml` uses `[[bin]] name = "thoughts"`, the binary name is unchanged — Dockerfile needs no update.
**Note on worker:** `crates/worker/` is already a clean composition root — it wires its own deps in `main.rs`. No changes needed there.