← Оглавление

8.2 Одно состояние, два значения

Переписка, в которой один пишет а другой только читает, - не переписка. Командная строка пишет в журнал. Сервер читает из него при старте. Добавленное через сервер в журнал не попадает: исчезает, когда процесс заканчивается. Два входа, но только один из них оставляет след.

Что нужно

Обработчики видят хранилище. Путь к файлу - нет: он остался в main и дальше не пошёл. Без него сохранить нельзя.

Выход прямой: собрать хранилище и путь в одну структуру и передать её вместо одного только хранилища. Axum доставит её в каждый обработчик через State - так же, как раньше доставлял SharedStore. Единственное условие: структура должна реализовывать Clone, потому что axum клонирует состояние на каждый вызов. #[derive(Clone)] достаточно - оба поля его уже реализуют.

Постройка

В crates/api/src/routes.rs появляется AppState и импорт persistence:

// crates/api/src/routes.rs - CHANGED
use axum::{
    Json, Router,
    extract::{Path, State},
    routing::{get, patch, post},
};
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tq_core::persistence;
use tq_core::store::{Store, TaskStore};
use tq_core::task::Task;

pub type SharedStore = Arc<Mutex<TaskStore>>;

#[derive(Clone)] // axum клонирует состояние для каждого вызова
pub struct AppState {
    pub store: SharedStore,
    pub data_path: PathBuf,
}

#[derive(Deserialize)]
struct AddRequest {
    title: String,
}

async fn add(State(state): State<AppState>, Json(req): Json<AddRequest>) -> Json<Task> {
    let mut guard = state.store.lock().unwrap();
    let id = guard.add(req.title.as_str()).unwrap();
    persistence::save(guard.all(), &state.data_path).unwrap();
    Json(guard.get(id).unwrap().clone())
}

async fn list(State(state): State<AppState>) -> Json<Vec<Task>> {
    let guard = state.store.lock().unwrap();
    Json(guard.all().to_vec())
}

async fn get_task(State(state): State<AppState>, Path(id): Path<u64>) -> Json<Task> {
    let guard = state.store.lock().unwrap();
    Json(guard.get(id).unwrap().clone())
}

async fn done(State(state): State<AppState>, Path(id): Path<u64>) -> Json<Task> {
    let mut guard = state.store.lock().unwrap();
    let task = guard.get_mut(id).unwrap();
    task.complete();
    let task = task.clone(); // завершает мутабельный заём - guard.all() требует иммутабельный
    persistence::save(guard.all(), &state.data_path).unwrap();
    Json(task)
}

pub fn router(state: AppState) -> Router {
    Router::new()
        .route("/tasks", post(add).get(list))
        .route("/tasks/{id}", get(get_task))
        .route("/tasks/{id}/done", patch(done))
        .with_state(state)
}

В add persistence::save идёт прямо перед возвратом. В done чуть иначе: task - это мутабельная ссылка из guard, и пока она жива, guard.all() взять нельзя - два займа на один guard одновременно. let task = task.clone() создаёт владеющее значение и завершает мутабельный заём; после этого guard.all() свободен.

Блокировка удерживается до конца save в обоих обработчиках - намеренно. Если отпустить её раньше, два одновременных запроса могут сохранить разные снимки и более старый затрёт более новый. Для tq это реальная потеря данных.

В crates/api/src/main.rs - собираем AppState из двух частей:

// crates/api/src/main.rs - CHANGED
mod routes;

use axum::{Router, routing::get};
use routes::{AppState, SharedStore};
use std::path::Path;
use std::sync::{Arc, Mutex};
use tq_core::config::Config;
use tq_core::persistence;
use tq_core::store::TaskStore;

async fn health() -> String {
    "tq ok".to_string()
}

#[tokio::main]
async fn main() {
    let config = Config::load(Path::new("config.toml"), None).unwrap_or_default();
    let data_path = config.data_dir.join("tasks.json");
    let tasks = persistence::load(&data_path).unwrap_or_default();
    let store: SharedStore = Arc::new(Mutex::new(TaskStore::new(tasks)));
    let state = AppState { store, data_path }; // два значения, одно состояние
    let app = Router::new()
        .route("/", get(health))
        .merge(routes::router(state));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Результат

$ rm -f tasks.json
$ make serve

В другом терминале - добавляем задачу через API, останавливаем сервер, запускаем снова:

$ curl -s -X POST localhost:3000/tasks -H "Content-Type: application/json" -d '{"title":"buy milk"}'
{"id":1,"title":"buy milk","status":"Todo","created_at":"2026-06-20T10:00:00Z"}

$ curl -s -X POST localhost:3000/tasks -H "Content-Type: application/json" -d '{"title":"send report"}'
{"id":2,"title":"send report","status":"Todo","created_at":"2026-06-20T10:05:00Z"}

Останавливаем сервер (Ctrl+C), запускаем снова (make serve):

$ curl -s localhost:3000/tasks
[{"id":1,"title":"buy milk","status":"Todo","created_at":"2026-06-20T10:00:00Z"},
 {"id":2,"title":"send report","status":"Todo","created_at":"2026-06-20T10:05:00Z"}]

Оба входа теперь оставляют след. CLI и API пишут в один журнал.

Полный код tq для этой главы - 8-ready/02-one-state-two-values/.