Depot Services Review Skill
Reviews Rust microservices in the depot/ directory that provide base station infrastructure for rover fleet operations.
Overview
The depot consists of 5 Rust-based microservices that handle rover discovery, mission dispatch, mapping, and GPS infrastructure:
Services Covered
| Service |
Port |
Purpose |
Database |
WebSocket |
| discovery |
4860 |
Rover registration, heartbeat tracking, session API |
No |
Yes (operator updates) |
| dispatch |
4890 |
Mission planning, task assignment, zone management |
PostgreSQL |
Yes (rover + console) |
| map-api |
4870 |
Serve processed maps and sessions |
No |
No |
| gps-status |
4880 |
RTK base station monitoring |
No |
Yes (console updates) |
| mapper |
- |
Map processing orchestrator (batch job) |
No |
No |
Shared Technology Stack
All services use a consistent technology stack:
Runtime & Async:
- Rust Edition 2021
- Tokio 1.41+ (async runtime with
rt-multi-thread, macros, net)
- Futures 0.3 (async utilities)
Web Framework:
- Axum 0.7-0.8 (web server, routing, WebSocket)
- Tower-HTTP 0.6 (middleware: CORS, compression, tracing)
- Hyper (underlying HTTP implementation)
Database (dispatch only):
- SQLx 0.8 (async PostgreSQL client)
- PostgreSQL 16+ (database server in Docker)
- JSON/JSONB for flexible schema fields
Serialization:
- Serde 1.0 (derive macros)
- serde_json 1.0 (JSON encoding/decoding)
Error Handling:
- thiserror 2.0 (library error types)
- anyhow (application-level error handling)
Logging:
- tracing 0.1 (structured logging)
- tracing-subscriber 0.3 (log formatting, env filtering)
Other:
- UUID 1.0 (unique identifiers with v4 generation)
- chrono 0.4 (timestamps with serde support)
Architecture Pattern
All services follow a consistent structure:
depot/<service>/
├── Cargo.toml # Dependencies and metadata
├── Dockerfile # Multi-stage build (alpine-based)
├── src/
│ └── main.rs # Single-file service (300-1200 LOC)
└── migrations/ # SQL migrations (dispatch only)
└── 001_initial.sql
Code Structure (main.rs):
//! Service documentation
// Imports
use axum::{...};
use tokio::...;
// Type definitions
struct AppState { ... }
type SharedState = Arc<AppState>;
// Main function
#[tokio::main]
async fn main() {
// 1. Initialize logging
// 2. Load configuration from env vars
// 3. Initialize state (DB connection, channels, etc.)
// 4. Create router with routes
// 5. Bind to port and serve
}
// Handler functions
async fn endpoint_handler(...) -> impl IntoResponse { ... }
// Helper functions
fn utility_function(...) { ... }
Shared Patterns Review Checklist
1. Axum Web Server Setup
- Uses #[tokio::main] macro for async main - Runtime features include at least: rt-multi-thread, macros, net - No blocking operations in async contexts
❌ BAD:
fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async { ... });
}
✅ GOOD:
#[tokio::main]
async fn main() {
// Tokio runtime handles everything
}
- Uses Router::new() with chained .route() calls - Groups related routes together (RESTful patterns) - WebSocket routes use get() method with ws.onupgrade() - State is attached with .withstate()
✅ GOOD:
let app = Router::new()
// CRUD for zones
.route("/zones", post(create_zone))
.route("/zones", get(list_zones))
.route("/zones/{id}", get(get_zone))
.route("/zones/{id}", put(update_zone))
.route("/zones/{id}", delete(delete_zone))
// WebSocket
.route("/ws", get(ws_handler))
// Health check
.route("/health", get(health))
.layer(CorsLayer::permissive())
.with_state(state);
- Uses CorsLayer::permissive() for internal services - Applied as layer via .layer(CorsLayer::permissive()) - Enables cross-origin requests from console
✅ GOOD:
use tower_http::cors::CorsLayer;
Router::new()
.route("/endpoint", get(handler))
.layer(CorsLayer::permissive()) // Allows all origins for internal services
- Port loaded from PORT env var with sensible default - Binds to 0.0.0.0 (all interfaces) for Docker compatibility - Uses tokio::net::TcpListener for async binding
✅ GOOD:
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(4860); // Service-specific default
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
Reference: See [api-design-patterns.md](./api-design-patterns.md) for more Axum patterns.
2. State Management
- Database pool (dispatch only) - Broadcast channels for WebSocket updates - Configuration (directories, URLs, etc.) - Connected client tracking (if applicable)
✅ GOOD:
struct AppState {
db: PgPool, // Database connection pool
rovers: RwLock<HashMap<String, ConnectedRover>>, // Mutable state
broadcast_tx: broadcast::Sender<BroadcastMessage>, // Update channel
}
- Uses Arc<AppState> for clone-on-share semantics - Type alias SharedState = Arc<AppState> for convenience - Handlers receive State<SharedState> extractor
✅ GOOD:
type SharedState = Arc<AppState>;
let state = Arc::new(AppState::new(pool));
async fn handler(State(state): State<SharedState>) -> impl IntoResponse {
// Access state here
}
- Immutable state: direct access via Arc - Mutable state: RwLock for many readers, few writers - Channels: broadcast for fan-out, mpsc for point-to-point - Never use Mutex around async operations
❌ BAD:
let rovers = state.rovers.lock().await; // Mutex in async = deadlock risk
tokio::time::sleep(Duration::from_secs(5)).await; // Holding lock across await!
drop(rovers);
✅ GOOD:
// Read
let rovers = state.rovers.read().await;
let count = rovers.len();
drop(rovers); // Release lock quickly
// Write
let mut rovers = state.rovers.write().await;
rovers.insert(id, rover);
drop(rovers); // Release lock quickly
3. Database Patterns (Dispatch Service)
- Uses PgPoolOptions::new() to configure pool - Sets maxconnections appropriately (10-20 for services) - Connection URL from DATABASEURL env var - Handles connection errors gracefully
✅ GOOD:
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(10)
.connect(&database_url)
.await
.expect("Failed to connect to database");
- Migration SQL files in migrations/ directory - Applied manually in run_migrations() function - Checks if migration already applied before running - Logs migration status
✅ GOOD:
async fn run_migrations(pool: &PgPool) {
let migration = include_str!("../migrations/001_initial.sql");
// Check if already migrated
let table_exists: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'zones')"
)
.fetch_one(pool)
.await
.unwrap_or(false);
if !table_exists {
info!("Running migration...");
sqlx::raw_sql(migration).execute(pool).await.expect("Migration failed");
}
}
- NEVER use string concatenation for SQL - Always use .bind() for parameters - Prevents SQL injection vulnerabilities
❌ BAD (SQL injection vulnerability):
let query = format!("SELECT * FROM zones WHERE id = '{}'", user_input);
sqlx::query(&query).fetch_one(&pool).await?;
✅ GOOD:
sqlx::query_as::<_, Zone>(
"SELECT * FROM zones WHERE id = $1"
)
.bind(id) // Parameterized - safe!
.fetch_one(&pool)
.await?
- .fetchone() - exactly one row expected, error if zero or multiple - .fetchoptional() - zero or one row, returns Option<T> - .fetch_all() - multiple rows, returns Vec<T> - .execute() - for INSERT/UPDATE/DELETE, returns rows affected
✅ GOOD:
// Get by ID - must exist
let zone: Zone = sqlx::query_as("SELECT ... WHERE id = $1")
.bind(id)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Get by ID - may not exist
let zone: Option<Zone> = sqlx::query_as("SELECT ... WHERE id = $1")
.bind(id)
.fetch_optional(&state.db)
.await?;
- Use #[sqlx(json)] attribute on struct fields - Serialize/deserialize with serde - Type must be serde_json::Value in database model - Cast to concrete types when needed
✅ GOOD:
#[derive(FromRow, Serialize)]
struct Zone {
pub id: Uuid,
#[sqlx(json)]
pub waypoints: serde_json::Value, // Stored as JSONB
}
// When parsing
let waypoints: Vec<Waypoint> = serde_json::from_value(zone.waypoints)?;
Reference: See [database-patterns.md](./database-patterns.md) for comprehensive database guidance.
4. Error Handling
- Return type: Result<impl IntoResponse, (StatusCode, String)> - Errors map to HTTP status codes - Error messages are user-friendly (not debug strings)
✅ GOOD:
async fn get_zone(
State(state): State<SharedState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let zone = sqlx::query_as("...")
.bind(id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Zone not found".to_string()))?;
Ok(Json(zone))
}
- Derive from thiserror::Error for libraries - Implement IntoResponse for Axum handlers - Map error variants to appropriate HTTP status codes
✅ GOOD:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ApiError {
#[error("Not found: {0}")]
NotFound(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
let (status, message) = match &self {
ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
ApiError::Io(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
- .maperr() converts SQLx errors to HTTP responses - Use .okor() to convert Option to Result with NOT_FOUND - Log errors before returning them
✅ GOOD:
let zone: Zone = sqlx::query_as("SELECT ... WHERE id = $1")
.bind(id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
warn!(error = %e, "Database query failed");
(StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string())
})?
.ok_or_else(|| {
warn!(zone_id = %id, "Zone not found");
(StatusCode::NOT_FOUND, "Zone not found".to_string())
})?;
5. WebSocket Communication
- Handler accepts WebSocketUpgrade extractor - Returns ws.onupgrade(|socket| handlerfn(socket, state)) - Handler function is async fn taking WebSocket and state
✅ GOOD:
async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<SharedState>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_ws(socket, state))
}
async fn handle_ws(socket: WebSocket, state: SharedState) {
let (mut sender, mut receiver) = socket.split();
// ... WebSocket logic
}
- Use serdejson::fromstr() to parse text messages - Handle parse errors gracefully (don't crash connection) - Implement message protocol with tagged enum
✅ GOOD:
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
enum RoverMessage {
Register { rover_id: String },
Progress { task_id: Uuid, progress: i32 },
}
// In handler
while let Some(msg) = receiver.next().await {
let msg = match msg {
Ok(Message::Text(text)) => text,
Ok(Message::Close(_)) => break,
_ => continue,
};
match serde_json::from_str::<RoverMessage>(&msg) {
Ok(RoverMessage::Register { rover_id }) => { ... },
Ok(RoverMessage::Progress { task_id, progress }) => { ... },
Err(e) => warn!(error = %e, "Failed to parse message"),
}
}
- Create channel with broadcast::channel(capacity) - Subscribe with .subscribe() in each WebSocket handler - Send updates with .send() (returns Result) - Handle lagged receivers (broadcast can drop messages)
✅ GOOD:
// In AppState
struct AppState {
broadcast_tx: broadcast::Sender<BroadcastMessage>,
}
impl AppState {
fn new() -> Self {
let (broadcast_tx, _rx) = broadcast::channel(256); // Drop receiver
Self { broadcast_tx }
}
fn broadcast(&self, msg: BroadcastMessage) {
let _ = self.broadcast_tx.send(msg); // Ignore if no receivers
}
}
// In WebSocket handler
let mut rx = state.broadcast_tx.subscribe();
loop {
tokio::select! {
Ok(msg) = rx.recv() => {
// Send to client
}
msg = receiver.next() => {
// Handle incoming message
}
}
}
- Remove client from tracking map - Abort spawned tasks - Broadcast disconnect event if needed - No panics in cleanup code
✅ GOOD:
async fn handle_ws(socket: WebSocket, state: SharedState) {
let mut rover_id: Option<String> = None;
// WebSocket loop
while let Some(msg) = receiver.next().await {
// ... handle messages
if let RoverMessage::Register { rover_id: id } = parsed {
rover_id = Some(id.clone());
// Register rover
}
}
// Cleanup on disconnect
if let Some(id) = rover_id {
info!(rover_id = %id, "Client disconnected");
let mut rovers = state.rovers.write().await;
rovers.remove(&id);
drop(rovers);
state.broadcast(BroadcastMessage::Disconnected { rover_id: id });
}
}
Reference: See [websocket-patterns.md](./websocket-patterns.md) for detailed WebSocket guidance.
6. Logging and Observability
- Use tracingsubscriber::fmt() in main() - Set env filter with fallback: RUSTLOG env var or default level - Initialize before any other operations
✅ GOOD:
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "discovery=info,sqlx=warn".into())
)
.init();
info!("Service starting...");
// ... rest of main
}
- Use info!, warn!, error!, debug! macros - Include context with key-value pairs - Use % for Display, ? for Debug formatting - Log important events: startup, requests, errors, shutdown
✅ GOOD:
info!(
rover_id = %rover.id,
address = %rover.address,
"Rover registered"
);
warn!(
task_id = %task_id,
error = %error,
"Task failed"
);
- Endpoint at /health returning JSON - Includes service status and metrics - Returns 200 OK if service is healthy - Used by Docker healthcheck
✅ GOOD:
async fn health(State(state): State<SharedState>) -> impl IntoResponse {
let rover_count = state.rovers.read().await.len();
Json(serde_json::json!({
"status": "ok",
"rovers": rover_count
}))
}
7. Configuration and Environment
- Use std::env::var() for required config - Provide sensible defaults for optional config - Validate configuration early (fail fast) - Log configuration values (except secrets)
✅ GOOD:
// Required
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
// Optional with default
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(4860);
let sessions_dir = std::env::var("SESSIONS_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/data/sessions"));
info!(
port = port,
sessions_dir = %sessions_dir.display(),
"Configuration loaded"
);
- Never hardcode paths with / or \ - Use PathBuf::from() to convert from env vars - Use .join() to build paths - Use .display() for logging paths
✅ GOOD:
let base_dir = PathBuf::from(std::env::var("DATA_DIR").unwrap_or("/data".into()));
let sessions_path = base_dir.join("sessions").join(&rover_id);
info!(path = %sessions_path.display(), "Session path");
8. Docker Build and Deployment
- Stage 1: rust:1.83-alpine for building - Stage 2: alpine:3.21 for runtime - Minimizes final image size (typically 10-20 MB)
✅ GOOD:
# Build stage
FROM rust:1.83-alpine AS builder
RUN apk add --no-cache musl-dev
WORKDIR /app
COPY Cargo.toml Cargo.lock* ./
# Dummy build for caching
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release 2>/dev/null || true
# Real build
COPY src ./src
RUN touch src/main.rs
RUN cargo build --release
# Runtime stage
FROM alpine:3.21
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/target/release/[service] /usr/local/bin/[service]
ENV PORT=4860
EXPOSE 4860
CMD ["[service]"]
- lto = true - Link-time optimization - opt-level = "z" - Optimize for size - strip = true - Remove debug symbols
✅ GOOD in Cargo.toml:
[profile.release]
lto = true
opt-level = "z"
strip = true
- Container name follows pattern: depot-<service> - Restart policy: unless-stopped - Ports mapped correctly (host:container) - Environment variables passed from .env - Healthcheck defined - Dependencies declared with depends_on
✅ GOOD in docker-compose.yml:
discovery:
build:
context: ./discovery
dockerfile: Dockerfile
container_name: depot-discovery
restart: unless-stopped
ports:
- "4860:4860"
environment:
- PORT=4860
- RUST_LOG=discovery=info
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://localhost:4860/health"]
interval: 10s
timeout: 3s
retries: 3
Reference: See [docker-deployment.md](./docker-deployment.md) for comprehensive Docker patterns.
Service-Specific Reviews
Discovery Service (depot/discovery)
Purpose: Rover registration, heartbeat tracking, session file serving
Key Files:
src/main.rs - Main service implementation (~600 LOC)
Dockerfile - Multi-stage build
Endpoints:
POST /register - Register rover with metadata
POST /heartbeat/{id} - Update rover status with telemetry
GET /rovers - List all registered rovers (HTTP fallback)
GET /ws - WebSocket for live rover updates to operators
GET /api/sessions - List recorded sessions
GET /api/sessions/{roverid}/{sessiondir}/session.rrd - Serve session file
GET /health - Health check
Specific Concerns:
- Constant ROVERTIMEOUT = Duration::fromsecs(10) - Background task checks for stale rovers every 2 seconds - Online status computed on-the-fly in get_rovers()
- Path traversal attacks prevented (no .. in paths) - Files served from configured SESSIONS_DIR only - Tries both direct and nested session structures - Returns 404 if file doesn't exist
- Initial rover list sent on connection - Updates only sent when state changes - Broadcast channel prevents blocking on slow clients
Common Issues:
- Forgetting to call
state.notify() after state changes
- Not dropping read locks before broadcasting (potential deadlock)
- Not handling both session directory structures (direct vs nested)
Dispatch Service (depot/dispatch)
Purpose: Mission planning, zone management, task assignment to rovers
Key Files:
src/main.rs - Main service implementation (~1200 LOC)
migrations/001_initial.sql - Database schema
Dockerfile - Multi-stage build
Database Tables:
zones - Geographic areas (routes, polygons, points)
missions - Scheduled work definitions
tasks - Execution instances with progress tracking
Endpoints:
POST/GET/PUT/DELETE /zones - Zone CRUD
POST/GET/PUT/DELETE /missions - Mission CRUD
POST /missions/{id}/start - Start mission (creates task, assigns to rover)
POST /missions/{id}/stop - Stop mission (cancels active task)
GET/POST /tasks - Task management
GET /ws - WebSocket for rover connections
GET /ws/console - WebSocket for console updates
GET /health - Health check
Specific Concerns:
- States: pending → assigned → active → done/failed/cancelled - startedat set on first progress update - endedat set when task completes/fails/cancelled - Rover's current_task cleared when task ends
- Two WebSocket endpoints: /ws (rovers), /ws/console (operators) - Rover messages: Register, Progress, Complete, Failed - Dispatch messages: Task, Cancel - Broadcast messages: TaskUpdate, RoverUpdate, ZoneUpdate, MissionUpdate
- Check if mission has preferred rover, verify connected - If no preference, find any available rover (no current task) - Create task in database with status=assigned - Update rover's current_task in memory - Send task to rover via WebSocket - Rollback if send fails
- zones.waypoints contains array of {x, y, theta?} objects - missions.schedule contains {trigger, cron?, loop} object - Validate structure before inserting (prevent invalid data)
Common Issues:
- Not clearing
rover.current_task when task ends (rover stuck)
- Forgetting to broadcast updates to console clients
- Not rolling back database changes if WebSocket send fails
- Race condition between task creation and rover disconnect
Map API Service (depot/map-api)
Purpose: Serve processed maps and 3D assets to console and other clients
Key Files:
src/main.rs - Main service implementation (~450 LOC)
Dockerfile - Multi-stage build
Endpoints:
GET /maps - List all maps
GET /maps/{id} - Get map manifest (metadata)
GET /maps/{id}/{asset} - Download asset (splat.ply, pointcloud.laz, mesh.glb, thumbnail.jpg)
GET /sessions - List all sessions
GET /sessions/{id} - Get session metadata
GET /maps/{id}/sessions - Get sessions used to build map
GET /health - Health check
Specific Concerns:
- Reads maps/index.json for map list - Lazily loads manifests from maps/{name}/manifest.json - Caches manifests in RwLock<HashMap<Uuid, MapManifest>> - Reloads on each list/get request (eventual consistency)
- Validates asset exists in manifest before serving - Sets correct Content-Type header for each asset type - Reads entire file into memory (acceptable for small assets) - Returns 404 if asset file missing
- Uses PathBuf::join() to build paths - Validates file exists before serving - No path traversal vulnerabilities
Common Issues:
- Serving assets not listed in manifest (security issue)
- Not setting Content-Type header (browser confusion)
- Not handling missing files gracefully
GPS Status Service (depot/gps-status)
Purpose: Monitor RTK base station status and broadcast to console
Key Files:
src/main.rs - Main service implementation (~400 LOC)
Dockerfile - Multi-stage build
Endpoints:
GET /status - Current RTK base station status
GET /ws - WebSocket for live status updates to console
GET /health - Health check
Specific Concerns:
- Background task polls base station via serial/TCP - Parses RTCM3/NMEA messages for status - Broadcasts status updates via WebSocket - Handles base station disconnection gracefully
- Status sent at reasonable interval (1-5 seconds) - Prevents overwhelming clients with updates - Initial status sent on connection
Common Issues:
- Not handling base station disconnection
- Sending updates too frequently (CPU/bandwidth waste)
- Not validating RTCM3 message checksums
Mapper Service (depot/mapper)
Purpose: Orchestrate map processing pipeline (batch job, not always running)
Key Files:
src/main.rs - Main orchestrator (~1000 LOC)
Dockerfile - Multi-stage build
Operation:
- Runs as batch job (not a long-running service)
- Scans sessions directory for new sessions
- Queues sessions for processing
- Invokes splat-worker for 3D reconstruction
- Generates map manifests and assets
- Updates index.json
Specific Concerns:
- Scans filesystem for new sessions - Reads metadata.json from each session - Filters by GPS bounds, frame counts, etc. - Deduplicates sessions already processed
- Tracks session status (pending, processing, processed, failed) - Retries failed sessions with exponential backoff - Logs errors for manual intervention - Doesn't block on failed sessions
- Calculates GPS bounds from all sessions - Lists all available assets (splat, pointcloud, mesh, thumbnail) - Includes session references - Updates index.json atomically
Common Issues:
- Not handling concurrent mapper invocations (file conflicts)
- Not validating session metadata before processing
- Not cleaning up temporary files on failure
Quick Commands
Development
# Check code (no build)
cargo check -p discovery
cargo check -p dispatch
# Build service
cargo build -p discovery --release
# Run tests
cargo test -p dispatch
# Run service locally (requires dependencies)
cd depot/discovery
PORT=4860 cargo run
# Run with logging
RUST_LOG=discovery=debug cargo run
Docker
# Build service image
cd depot/discovery
docker build -t depot-discovery .
# Run service container
docker run -p 4860:4860 -e RUST_LOG=info depot-discovery
# Build all services via Docker Compose
cd depot
docker compose build
# Start all services
docker compose up -d
# View logs
docker compose logs -f discovery
# Restart service
docker compose restart dispatch
# Stop all services
docker compose down
Database (Dispatch Only)
# Connect to PostgreSQL
docker compose exec postgres psql -U postgres -d dispatch
# View tables
\dt
# Query zones
SELECT id, name, zone_type FROM zones;
# Query tasks
SELECT id, status, rover_id, progress FROM tasks ORDER BY created_at DESC LIMIT 10;
# Reset database (DESTRUCTIVE)
docker compose down -v # Removes volumes
docker compose up -d postgres
docker compose restart dispatch # Migrations run on startup
References
- [database-patterns.md](./database-patterns.md) - SQLx, migrations, queries, transactions
- [api-design-patterns.md](./api-design-patterns.md) - Axum routing, middleware, error handling
- [websocket-patterns.md](./websocket-patterns.md) - WebSocket protocols, broadcast channels
- [docker-deployment.md](./docker-deployment.md) - Multi-stage builds, Docker Compose, healthchecks
- [CLAUDE.md](../../../CLAUDE.md) - Project-wide conventions
- [depot/README.md](../../../depot/README.md) - Depot architecture overview