Skip to main content

max / makenotwork

Decompose AppState Phase 0: FromRef seam + build constructor Derive FromRef on AppState so handlers can extract narrow slices (State<PgPool>, State<Config>, State<EmailClient>, State<AppStorage>, ...) instead of the whole god struct. Skip the two plain time fields so no handler extracts a bare State<Instant>. Extract AppState::build(AppStateParts) from the inline struct literal in main.rs; the constructor fills in derived in-memory state (start times, empty cache maps, semaphores from constants) in one place. Move the TierPrices::install_global side effect out of the literal into an explicit ordered step at the call site. Add a type-level from_ref_slices guard test proving each slice stays FromRef-extractable. Runtime unchanged; no handler behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 18:37 UTC
Commit: 9740007468a348336cfdbb4861d4b08aa63c56fe
Parent: af35b4c
2 files changed, +136 insertions, -39 deletions
M server/src/lib.rs +112 -3
@@ -51,7 +51,7 @@ pub mod wordlist;
51 51 #[cfg(test)]
52 52 mod deploy_lint;
53 53
54 - use axum::{http::HeaderValue, middleware, Router};
54 + use axum::{extract::FromRef, http::HeaderValue, middleware, Router};
55 55 use std::time::Instant;
56 56 use tower_http::limit::RequestBodyLimitLayer;
57 57 use tower_http::services::ServeDir;
@@ -77,8 +77,15 @@ use scanning::ScanPipeline;
77 77 use storage::StorageBackend;
78 78 use webauthn_rs::Webauthn;
79 79
80 - /// Application state shared across all handlers
81 - #[derive(Clone)]
80 + /// Application state shared across all handlers.
81 + ///
82 + /// `#[derive(FromRef)]` generates `FromRef<AppState>` for every field type, so a
83 + /// handler can extract just the slice it needs — `State<PgPool>`, `State<Config>`,
84 + /// `State<AppStorage>`, `State<EmailClient>`, etc. — instead of the whole struct.
85 + /// This narrows each handler's declared dependencies without changing the runtime
86 + /// (the state is still one `Clone`-cheap value shared by every task). See
87 + /// `_private/docs/mnw/appstate-decomposition-plan.md`.
88 + #[derive(Clone, FromRef)]
82 89 pub struct AppState {
83 90 pub db: sqlx::PgPool,
84 91 pub config: Config,
@@ -96,7 +103,12 @@ pub struct AppState {
96 103 pub scanner: Option<Arc<ScanPipeline>>,
97 104 pub webauthn: Arc<Webauthn>,
98 105 pub syntax: Option<Arc<git::SyntaxHighlighter>>,
106 + // Plain timestamp types: skip FromRef so no handler extracts a bare
107 + // `State<DateTime<Utc>>` / `State<Instant>` and to keep those types free for
108 + // an `Ops` slice in Phase 1.
109 + #[from_ref(skip)]
99 110 pub started_at: chrono::DateTime<chrono::Utc>,
111 + #[from_ref(skip)]
100 112 pub start_instant: Instant,
101 113 /// HTTP client for the Multithreaded internal API (community/thread provisioning).
102 114 pub mt_client: Option<mt_client::MtClient>,
@@ -172,7 +184,81 @@ pub struct AppLimiters {
172 184 pub git_smart_http_semaphore: Arc<tokio::sync::Semaphore>,
173 185 }
174 186
187 + /// Externally-wired dependencies handed to [`AppState::build`].
188 + ///
189 + /// Holds every field the caller must construct from the environment (pools,
190 + /// clients, loaded config, the warmed domain cache). [`AppState::build`] assembles
191 + /// these into an [`AppState`] and fills in the purely-derived in-memory state
192 + /// (start timestamps, the empty cache maps, the concurrency semaphores) itself, so
193 + /// those defaults live in one place instead of being spelled out at each call site.
194 + pub struct AppStateParts {
195 + pub db: sqlx::PgPool,
196 + pub config: Config,
197 + pub storage: AppStorage,
198 + pub stripe: Option<Arc<dyn PaymentProvider>>,
199 + pub email: EmailClient,
200 + pub docs: Arc<DocLoader>,
201 + pub tier_prices: tier_prices::TierPrices,
202 + pub runway_config: tier_prices::RunwayConfig,
203 + pub pricing_comparison: pricing_comparison::PricingComparison,
204 + pub scanner: Option<Arc<ScanPipeline>>,
205 + pub webauthn: Arc<Webauthn>,
206 + pub syntax: Option<Arc<git::SyntaxHighlighter>>,
207 + pub mt_client: Option<mt_client::MtClient>,
208 + pub wam: Option<wam_client::WamClient>,
209 + /// Custom-domain cache, already warmed from the DB by the caller.
210 + pub domain_cache: Arc<DashMap<String, db::UserId>>,
211 + pub metrics_handle: Option<metrics_exporter_prometheus::PrometheusHandle>,
212 + pub page_view_tx: db::page_views::PageViewTx,
213 + pub bg: background::BackgroundTx,
214 + }
215 +
175 216 impl AppState {
217 + /// Assemble an [`AppState`] from its externally-wired dependencies.
218 + ///
219 + /// The caller builds the pieces that depend on the environment (DB pool, S3
220 + /// clients, Stripe, loaded docs, derived pricing, the warmed domain cache) and
221 + /// passes them in via [`AppStateParts`]; this fills in the purely-derived
222 + /// in-memory state (start timestamps, empty session/SSE caches, concurrency
223 + /// semaphores sized from [`constants`]). It has no process-global side effects:
224 + /// `TierPrices::install_global` is an explicit step at the call site, kept out
225 + /// of construction so the ordering stays visible.
226 + pub fn build(parts: AppStateParts) -> Self {
227 + Self {
228 + db: parts.db,
229 + config: parts.config,
230 + storage: parts.storage,
231 + stripe: parts.stripe,
232 + email: parts.email,
233 + docs: parts.docs,
234 + tier_prices: parts.tier_prices,
235 + runway_config: parts.runway_config,
236 + pricing_comparison: parts.pricing_comparison,
237 + scanner: parts.scanner,
238 + webauthn: parts.webauthn,
239 + syntax: parts.syntax,
240 + started_at: chrono::Utc::now(),
241 + start_instant: Instant::now(),
242 + mt_client: parts.mt_client,
243 + wam: parts.wam,
244 + restart_at: Arc::new(std::sync::atomic::AtomicI64::new(0)),
245 + metrics_handle: parts.metrics_handle,
246 + page_view_tx: parts.page_view_tx,
247 + bg: parts.bg,
248 + caches: AppCaches {
249 + session_cache: Arc::new(DashMap::new()),
250 + domain_cache: parts.domain_cache,
251 + sync_notify: Arc::new(DashMap::new()),
252 + sse_connections: Arc::new(DashMap::new()),
253 + },
254 + limiters: AppLimiters {
255 + scan_semaphore: Arc::new(tokio::sync::Semaphore::new(constants::SCAN_MAX_CONCURRENT)),
256 + caddy_ask_semaphore: Arc::new(tokio::sync::Semaphore::new(constants::CADDY_ASK_MAX_CONCURRENT)),
257 + git_smart_http_semaphore: Arc::new(tokio::sync::Semaphore::new(constants::GIT_SMART_HTTP_MAX_CONCURRENT)),
258 + },
259 + }
260 + }
261 +
176 262 /// Get the main S3 storage backend, or error if not configured.
177 263 pub fn require_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
178 264 self.storage.s3
@@ -471,6 +557,29 @@ async fn security_headers_middleware(
471 557 }
472 558
473 559 #[cfg(test)]
560 + mod from_ref_slices {
561 + //! Compile-time proof of the decomposition seam: `#[derive(FromRef)]` on
562 + //! [`AppState`] generates `FromRef<AppState>` for each field type, so a Phase 2
563 + //! handler can extract a narrow slice as `State<T>` instead of the whole
564 + //! struct. If any of these stop being extractable (a field renamed/removed, the
565 + //! derive dropped), this fails to compile — catching it before the handler
566 + //! migrations do. Type-level only: no `AppState` value or DB required.
567 + use super::*;
568 +
569 + fn _assert_from_ref<T: FromRef<AppState>>() {}
570 +
571 + #[test]
572 + fn slices_are_extractable() {
573 + _assert_from_ref::<sqlx::PgPool>();
574 + _assert_from_ref::<Config>();
575 + _assert_from_ref::<EmailClient>();
576 + _assert_from_ref::<AppStorage>();
577 + _assert_from_ref::<AppCaches>();
578 + _assert_from_ref::<AppLimiters>();
579 + }
580 + }
581 +
582 + #[cfg(test)]
474 583 mod timeout_exempt_tests {
475 584 use super::timeout_exempt;
476 585
M server/src/main.rs +24 -36
@@ -3,7 +3,7 @@
3 3 use axum::http::Request;
4 4 use sqlx::ConnectOptions;
5 5 use sqlx::postgres::PgPoolOptions;
6 - use std::time::{Duration, Instant};
6 + use std::time::Duration;
7 7 use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
8 8 use tower_http::trace::TraceLayer;
9 9 use tower_sessions::cookie::time::Duration as CookieDuration;
@@ -19,7 +19,7 @@ use makenotwork::email::{EmailClient, EmailConfig};
19 19 use makenotwork::payments::StripeClient;
20 20 use makenotwork::storage::S3Client;
21 21 use makenotwork::scanning::ScanPipeline;
22 - use makenotwork::{build_app, AppCaches, AppLimiters, AppState, AppStorage};
22 + use makenotwork::{build_app, AppState, AppStorage};
23 23 use webauthn_rs::WebauthnBuilder;
24 24
25 25 #[tokio::main]
@@ -445,58 +445,46 @@ async fn main() {
445 445 }
446 446 }
447 447
448 - let started_at = chrono::Utc::now();
449 - let start_instant = Instant::now();
450 448 // Shutdown broadcast: dropped at graceful-shutdown time to signal the
451 449 // background pool, monitor, scheduler, and scan workers to drain and stop.
452 450 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(());
453 451 let page_view_tx = makenotwork::db::page_views::spawn_batcher(db.clone());
454 452 let (bg, bg_handle) = makenotwork::background::spawn_pool(shutdown_tx.subscribe());
455 - let state = AppState {
453 +
454 + // Derive pricing from the loaded assumptions. Installing the process-global
455 + // TierPrices is an explicit, ordered step here (not buried in AppState
456 + // construction): CreatorTier accessors (price_cents, max_file_bytes,
457 + // max_storage_bytes) read the global, so it must be installed before any code
458 + // path constructs a CreatorTier and calls one of them.
459 + let tier_prices = makenotwork::tier_prices::TierPrices::from_assumptions(&assumptions);
460 + tier_prices.clone().install_global();
461 + let runway_config = makenotwork::tier_prices::RunwayConfig::from_assumptions(&assumptions);
462 + let pricing_comparison = makenotwork::pricing_comparison::PricingComparison::load(&assumptions_path);
463 +
464 + let state = AppState::build(makenotwork::AppStateParts {
456 465 db,
457 466 config: config.clone(),
467 + storage: AppStorage {
468 + s3,
469 + synckit_s3,
470 + public_s3,
471 + },
458 472 stripe,
459 473 email,
460 474 docs,
461 - tier_prices: {
462 - let tp = makenotwork::tier_prices::TierPrices::from_assumptions(&assumptions);
463 - // Install as the process-global for CreatorTier accessors
464 - // (price_cents, max_file_bytes, max_storage_bytes). Must precede
465 - // any code path that constructs a CreatorTier and calls one of
466 - // these methods.
467 - tp.clone().install_global();
468 - tp
469 - },
470 - runway_config: makenotwork::tier_prices::RunwayConfig::from_assumptions(&assumptions),
471 - pricing_comparison: makenotwork::pricing_comparison::PricingComparison::load(&assumptions_path),
475 + tier_prices,
476 + runway_config,
477 + pricing_comparison,
472 478 scanner,
473 479 webauthn,
474 480 syntax,
475 - started_at,
476 - start_instant,
477 481 mt_client,
478 482 wam,
479 - restart_at: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)),
483 + domain_cache,
480 484 metrics_handle: Some(makenotwork::metrics::init()),
481 485 page_view_tx,
482 486 bg,
483 - storage: AppStorage {
484 - s3,
485 - synckit_s3,
486 - public_s3,
487 - },
488 - caches: AppCaches {
489 - session_cache: std::sync::Arc::new(dashmap::DashMap::new()),
490 - domain_cache,
491 - sync_notify: std::sync::Arc::new(dashmap::DashMap::new()),
492 - sse_connections: std::sync::Arc::new(dashmap::DashMap::new()),
493 - },
494 - limiters: AppLimiters {
495 - scan_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(makenotwork::constants::SCAN_MAX_CONCURRENT)),
496 - caddy_ask_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(makenotwork::constants::CADDY_ASK_MAX_CONCURRENT)),
497 - git_smart_http_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(makenotwork::constants::GIT_SMART_HTTP_MAX_CONCURRENT)),
498 - },
499 - };
487 + });
500 488
501 489 // Log active features at startup
502 490 tracing::info!(