Skip to main content

max / makenotwork

Decompose AppState Phase 2: migrate builds to slices Second leaf module. Every builds handler is db-only except hook_trigger, which also reads the build trigger token from config: - 8 handlers (config CRUD, manual_trigger, list/get/cancel build): State<PgPool> - hook_trigger: State<PgPool> + State<Config> verify_app_owner takes &PgPool. No behavior change; 10 builds integration tests pass (including hook_trigger_unconfigured and manual_trigger). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 19:25 UTC
Commit: 5e08c097225f1080382f64fca39442107b1881c9
Parent: 5bf1e63
1 file changed, +43 insertions, -40 deletions
@@ -14,7 +14,10 @@ use chrono::{DateTime, Utc};
14 14 use serde::{Deserialize, Serialize};
15 15 use tower_governor::GovernorLayer;
16 16
17 + use sqlx::PgPool;
18 +
17 19 use crate::{
20 + config::Config,
18 21 constants,
19 22 csrf::{post_csrf_skip, with_csrf_skip, CsrfRouter},
20 23 db::{self, BuildConfigId, BuildId, BuildStatus, GitRepoId, OtaReleaseId, SyncAppId},
@@ -79,11 +82,11 @@ fn tag_to_version(tag: &str) -> Result<String> {
79 82
80 83 /// Verify the authenticated user owns the given sync app.
81 84 async fn verify_app_owner(
82 - state: &AppState,
85 + db: &PgPool,
83 86 sync_user: &SyncUser,
84 87 app_id: SyncAppId,
85 88 ) -> Result<db::DbSyncApp> {
86 - let app = db::synckit::get_sync_app_by_id(&state.db, app_id)
89 + let app = db::synckit::get_sync_app_by_id(db, app_id)
87 90 .await?
88 91 .ok_or(AppError::NotFound)?;
89 92
@@ -213,17 +216,17 @@ struct HookTriggerRequest {
213 216 /// `POST /api/sync/builds/apps/{app_id}/config`
214 217 #[tracing::instrument(skip_all, name = "builds::create_config")]
215 218 async fn create_config(
216 - State(state): State<AppState>,
219 + State(db): State<PgPool>,
217 220 sync_user: SyncUser,
218 221 Path(app_id): Path<SyncAppId>,
219 222 Json(req): Json<CreateConfigRequest>,
220 223 ) -> Result<impl IntoResponse> {
221 - verify_app_owner(&state, &sync_user, app_id).await?;
224 + verify_app_owner(&db, &sync_user, app_id).await?;
222 225 validate_targets(&req.targets)?;
223 226 validate_build_config_fields(&req.build_command, &req.artifact_path, &req.signing_key_path)?;
224 227
225 228 // Verify repo ownership
226 - let repo = db::git_repos::get_repo_by_id(&state.db, req.repo_id)
229 + let repo = db::git_repos::get_repo_by_id(&db, req.repo_id)
227 230 .await?
228 231 .ok_or(AppError::NotFound)?;
229 232 if repo.user_id != sync_user.user_id {
@@ -231,7 +234,7 @@ async fn create_config(
231 234 }
232 235
233 236 let config = db::builds::create_build_config(
234 - &state.db,
237 + &db,
235 238 app_id,
236 239 req.repo_id,
237 240 &req.build_command,
@@ -249,13 +252,13 @@ async fn create_config(
249 252 /// `GET /api/sync/builds/apps/{app_id}/config`
250 253 #[tracing::instrument(skip_all, name = "builds::get_config")]
251 254 async fn get_config(
252 - State(state): State<AppState>,
255 + State(db): State<PgPool>,
253 256 sync_user: SyncUser,
254 257 Path(app_id): Path<SyncAppId>,
255 258 ) -> Result<impl IntoResponse> {
256 - verify_app_owner(&state, &sync_user, app_id).await?;
259 + verify_app_owner(&db, &sync_user, app_id).await?;
257 260
258 - let config = db::builds::get_build_config_by_app(&state.db, app_id)
261 + let config = db::builds::get_build_config_by_app(&db, app_id)
259 262 .await?
260 263 .ok_or(AppError::NotFound)?;
261 264
@@ -267,21 +270,21 @@ async fn get_config(
267 270 /// `PUT /api/sync/builds/apps/{app_id}/config`
268 271 #[tracing::instrument(skip_all, name = "builds::update_config")]
269 272 async fn update_config(
270 - State(state): State<AppState>,
273 + State(db): State<PgPool>,
271 274 sync_user: SyncUser,
272 275 Path(app_id): Path<SyncAppId>,
273 276 Json(req): Json<UpdateConfigRequest>,
274 277 ) -> Result<impl IntoResponse> {
275 - verify_app_owner(&state, &sync_user, app_id).await?;
278 + verify_app_owner(&db, &sync_user, app_id).await?;
276 279 validate_targets(&req.targets)?;
277 280 validate_build_config_fields(&req.build_command, &req.artifact_path, &req.signing_key_path)?;
278 281
279 - let existing = db::builds::get_build_config_by_app(&state.db, app_id)
282 + let existing = db::builds::get_build_config_by_app(&db, app_id)
280 283 .await?
281 284 .ok_or(AppError::NotFound)?;
282 285
283 286 let config = db::builds::update_build_config(
284 - &state.db,
287 + &db,
285 288 existing.id,
286 289 &req.build_command,
287 290 &req.artifact_path,
@@ -299,17 +302,17 @@ async fn update_config(
299 302 /// `DELETE /api/sync/builds/apps/{app_id}/config`
300 303 #[tracing::instrument(skip_all, name = "builds::delete_config")]
301 304 async fn delete_config(
302 - State(state): State<AppState>,
305 + State(db): State<PgPool>,
303 306 sync_user: SyncUser,
304 307 Path(app_id): Path<SyncAppId>,
305 308 ) -> Result<impl IntoResponse> {
306 - verify_app_owner(&state, &sync_user, app_id).await?;
309 + verify_app_owner(&db, &sync_user, app_id).await?;
307 310
308 - let config = db::builds::get_build_config_by_app(&state.db, app_id)
311 + let config = db::builds::get_build_config_by_app(&db, app_id)
309 312 .await?
310 313 .ok_or(AppError::NotFound)?;
311 314
312 - db::builds::delete_build_config(&state.db, config.id).await?;
315 + db::builds::delete_build_config(&db, config.id).await?;
313 316
314 317 Ok(StatusCode::NO_CONTENT)
315 318 }
@@ -319,16 +322,16 @@ async fn delete_config(
319 322 /// `POST /api/sync/builds/apps/{app_id}/trigger`
320 323 #[tracing::instrument(skip_all, name = "builds::manual_trigger")]
321 324 async fn manual_trigger(
322 - State(state): State<AppState>,
325 + State(db): State<PgPool>,
323 326 sync_user: SyncUser,
324 327 Path(app_id): Path<SyncAppId>,
325 328 Json(req): Json<ManualTriggerRequest>,
326 329 ) -> Result<impl IntoResponse> {
327 - verify_app_owner(&state, &sync_user, app_id).await?;
330 + verify_app_owner(&db, &sync_user, app_id).await?;
328 331
329 332 let version = tag_to_version(&req.tag)?;
330 333
331 - let config = db::builds::get_build_config_by_app(&state.db, app_id)
334 + let config = db::builds::get_build_config_by_app(&db, app_id)
332 335 .await?
333 336 .ok_or_else(|| AppError::BadRequest("No build config found for this app".to_string()))?;
334 337
@@ -336,14 +339,14 @@ async fn manual_trigger(
336 339 return Err(AppError::BadRequest("Build config is disabled".to_string()));
337 340 }
338 341
339 - if db::builds::has_active_build(&state.db, config.id).await? {
342 + if db::builds::has_active_build(&db, config.id).await? {
340 343 return Err(AppError::BadRequest(
341 344 "A build is already pending or running for this app".to_string(),
342 345 ));
343 346 }
344 347
345 348 let build =
346 - db::builds::create_build(&state.db, config.id, app_id, &version, &req.tag, "manual")
349 + db::builds::create_build(&db, config.id, app_id, &version, &req.tag, "manual")
347 350 .await?;
348 351
349 352 Ok((StatusCode::CREATED, Json(BuildResponse::from(build))))
@@ -354,14 +357,14 @@ async fn manual_trigger(
354 357 /// `GET /api/sync/builds/apps/{app_id}/builds`
355 358 #[tracing::instrument(skip_all, name = "builds::list_builds")]
356 359 async fn list_builds(
357 - State(state): State<AppState>,
360 + State(db): State<PgPool>,
358 361 sync_user: SyncUser,
359 362 Path(app_id): Path<SyncAppId>,
360 363 ) -> Result<impl IntoResponse> {
361 - verify_app_owner(&state, &sync_user, app_id).await?;
364 + verify_app_owner(&db, &sync_user, app_id).await?;
362 365
363 366 let builds =
364 - db::builds::list_builds_by_app(&state.db, app_id, constants::BUILD_HISTORY_LIMIT).await?;
367 + db::builds::list_builds_by_app(&db, app_id, constants::BUILD_HISTORY_LIMIT).await?;
365 368 let response: Vec<BuildResponse> = builds.into_iter().map(BuildResponse::from).collect();
366 369
367 370 Ok(Json(response))
@@ -372,13 +375,13 @@ async fn list_builds(
372 375 /// `GET /api/sync/builds/apps/{app_id}/builds/{build_id}`
373 376 #[tracing::instrument(skip_all, name = "builds::get_build")]
374 377 async fn get_build(
375 - State(state): State<AppState>,
378 + State(db): State<PgPool>,
376 379 sync_user: SyncUser,
377 380 Path((app_id, build_id)): Path<(SyncAppId, BuildId)>,
378 381 ) -> Result<impl IntoResponse> {
379 - verify_app_owner(&state, &sync_user, app_id).await?;
382 + verify_app_owner(&db, &sync_user, app_id).await?;
380 383
381 - let build = db::builds::get_build(&state.db, build_id)
384 + let build = db::builds::get_build(&db, build_id)
382 385 .await?
383 386 .ok_or(AppError::NotFound)?;
384 387
@@ -394,13 +397,13 @@ async fn get_build(
394 397 /// `POST /api/sync/builds/apps/{app_id}/builds/{build_id}/cancel`
395 398 #[tracing::instrument(skip_all, name = "builds::cancel_build")]
396 399 async fn cancel_build(
397 - State(state): State<AppState>,
400 + State(db): State<PgPool>,
398 401 sync_user: SyncUser,
399 402 Path((app_id, build_id)): Path<(SyncAppId, BuildId)>,
400 403 ) -> Result<impl IntoResponse> {
401 - verify_app_owner(&state, &sync_user, app_id).await?;
404 + verify_app_owner(&db, &sync_user, app_id).await?;
402 405
403 - let build = db::builds::get_build(&state.db, build_id)
406 + let build = db::builds::get_build(&db, build_id)
404 407 .await?
405 408 .ok_or(AppError::NotFound)?;
406 409
@@ -415,7 +418,7 @@ async fn cancel_build(
415 418 }
416 419
417 420 db::builds::update_build_status(
418 - &state.db,
421 + &db,
419 422 build_id,
420 423 BuildStatus::Cancelled,
421 424 Some("Cancelled by user"),
@@ -432,14 +435,14 @@ async fn cancel_build(
432 435 /// `POST /api/internal/builds/trigger`
433 436 #[tracing::instrument(skip_all, name = "builds::hook_trigger")]
434 437 async fn hook_trigger(
435 - State(state): State<AppState>,
438 + State(db): State<PgPool>,
439 + State(config): State<Config>,
436 440 headers: axum::http::HeaderMap,
437 441 Json(req): Json<HookTriggerRequest>,
438 442 ) -> Result<impl IntoResponse> {
439 443 // Authenticate via per-repo HMAC derived from BUILD_TRIGGER_TOKEN.
440 444 // The hook file contains HMAC(token, owner:repo), not the raw token.
441 - let trigger_token = state
442 - .config
445 + let trigger_token = config
443 446 .build.trigger_token
444 447 .as_deref()
445 448 .ok_or_else(|| AppError::ServiceUnavailable("Build triggers not configured".to_string()))?;
@@ -462,7 +465,7 @@ async fn hook_trigger(
462 465
463 466 // Look up repo by owner + name
464 467 let owner = db::users::get_user_by_username(
465 - &state.db,
468 + &db,
466 469 &db::Username::new(&req.repo_owner)
467 470 .map_err(|_| AppError::BadRequest("Invalid repo owner".to_string()))?,
468 471 )
@@ -470,25 +473,25 @@ async fn hook_trigger(
470 473 .ok_or(AppError::NotFound)?;
471 474
472 475 let repo =
473 - db::git_repos::get_repo_by_user_and_name(&state.db, owner.id, &req.repo_name)
476 + db::git_repos::get_repo_by_user_and_name(&db, owner.id, &req.repo_name)
474 477 .await?
475 478 .ok_or(AppError::NotFound)?;
476 479
477 480 // Find build config for this repo
478 - let config = db::builds::get_build_config_by_repo(&state.db, repo.id)
481 + let config = db::builds::get_build_config_by_repo(&db, repo.id)
479 482 .await?
480 483 .ok_or_else(|| {
481 484 AppError::BadRequest("No build config found for this repository".to_string())
482 485 })?;
483 486
484 - if db::builds::has_active_build(&state.db, config.id).await? {
487 + if db::builds::has_active_build(&db, config.id).await? {
485 488 return Err(AppError::BadRequest(
486 489 "A build is already pending or running".to_string(),
487 490 ));
488 491 }
489 492
490 493 let build = db::builds::create_build(
491 - &state.db,
494 + &db,
492 495 config.id,
493 496 config.app_id,
494 497 &version,