brewlog/src/infrastructure/backup.rs
Jon Seager 97c00f2e33
feat(stats): add pre-computed stats cache with background recomputation
- Add CachedStats domain types (roast summary, consumption, brewing)
- Extend StatsRepository with summary queries and cache get/store
- Add StatsInvalidator + background task with 2s debounce
- Add invalidate() calls to all entity create/update/delete handlers
- Add POST /api/v1/stats/recompute endpoint for manual refresh
- Add stats_cache migration and include in database reset
- Add GeoStats::from_counts() and Serialize/Deserialize derives
- Add 6 integration tests for stats API
- Document Stats Cache pattern in CLAUDE.md
2026-02-08 15:20:01 +00:00

775 lines
23 KiB
Rust

use std::str::FromStr;
use anyhow::{Context, bail};
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{from_str, to_string};
use crate::domain::bags::Bag;
use crate::domain::brews::{Brew, QuickNote};
use crate::domain::cafes::Cafe;
use crate::domain::cups::Cup;
use crate::domain::gear::{Gear, GearCategory};
use crate::domain::ids::{
BagId, BrewId, CafeId, CupId, GearId, RoastId, RoasterId, TimelineEventId,
};
use crate::domain::roasters::Roaster;
use crate::domain::roasts::Roast;
use crate::domain::timeline::TimelineEvent;
use crate::infrastructure::database::{DatabasePool, DatabaseTransaction};
fn decode_json_vec<T: serde::de::DeserializeOwned>(
raw: Option<String>,
label: &str,
) -> anyhow::Result<Vec<T>> {
match raw {
Some(s) if !s.is_empty() => {
from_str(&s).with_context(|| format!("failed to decode {label}: {s}"))
}
_ => Ok(Vec::new()),
}
}
fn decode_json_opt<T: serde::de::DeserializeOwned>(
raw: Option<String>,
label: &str,
) -> anyhow::Result<Option<T>> {
match raw {
Some(s) if !s.is_empty() => from_str(&s)
.map(Some)
.with_context(|| format!("failed to decode {label}: {s}")),
_ => Ok(None),
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BackupData {
pub version: u32,
pub created_at: DateTime<Utc>,
pub roasters: Vec<Roaster>,
pub gear: Vec<Gear>,
pub roasts: Vec<Roast>,
pub bags: Vec<Bag>,
pub brews: Vec<Brew>,
#[serde(default)]
pub cafes: Vec<Cafe>,
#[serde(default)]
pub cups: Vec<Cup>,
pub timeline_events: Vec<TimelineEvent>,
}
pub struct BackupService {
pool: DatabasePool,
}
impl BackupService {
pub fn new(pool: DatabasePool) -> Self {
Self { pool }
}
pub async fn export(&self) -> anyhow::Result<BackupData> {
let roasters = self.export_roasters().await?;
let gear = self.export_gear().await?;
let roasts = self.export_roasts().await?;
let bags = self.export_bags().await?;
let brews = self.export_brews().await?;
let cafes = self.export_cafes().await?;
let cups = self.export_cups().await?;
let timeline_events = self.export_timeline_events().await?;
Ok(BackupData {
version: 2,
created_at: Utc::now(),
roasters,
gear,
roasts,
bags,
brews,
cafes,
cups,
timeline_events,
})
}
pub async fn restore(&self, data: BackupData) -> anyhow::Result<()> {
self.verify_empty_database().await?;
let mut tx = self
.pool
.begin()
.await
.context("failed to begin transaction")?;
self.restore_roasters(&mut tx, &data.roasters).await?;
self.restore_gear(&mut tx, &data.gear).await?;
self.restore_roasts(&mut tx, &data.roasts).await?;
self.restore_bags(&mut tx, &data.bags).await?;
self.restore_brews(&mut tx, &data.brews).await?;
self.restore_cafes(&mut tx, &data.cafes).await?;
self.restore_cups(&mut tx, &data.cups).await?;
self.restore_timeline_events(&mut tx, &data.timeline_events)
.await?;
tx.commit().await.context("failed to commit transaction")?;
Ok(())
}
/// Delete all coffee data, leaving auth tables intact.
///
/// After a successful reset the database is in the same state that
/// `verify_empty_database()` requires, so a subsequent `restore()` will
/// succeed.
pub async fn reset(&self) -> anyhow::Result<()> {
let mut tx = self
.pool
.begin()
.await
.context("failed to begin transaction")?;
// Delete in FK-safe order: children before parents.
// brews has RESTRICT FK → gear; cups has RESTRICT FK → roasts, cafes.
let tables = [
"brews",
"cups",
"bags",
"roasts",
"timeline_events",
"gear",
"cafes",
"roasters",
"stats_cache",
];
for table in tables {
let query = format!("DELETE FROM {table}");
sqlx::query(&query)
.execute(&mut *tx)
.await
.with_context(|| format!("failed to delete from {table}"))?;
}
tx.commit().await.context("failed to commit transaction")?;
Ok(())
}
// --- Export methods ---
async fn export_roasters(&self) -> anyhow::Result<Vec<Roaster>> {
let records = sqlx::query_as::<_, RoasterRecord>(
"SELECT id, name, slug, country, city, homepage, created_at FROM roasters ORDER BY id",
)
.fetch_all(&self.pool)
.await
.context("failed to export roasters")?;
Ok(records
.into_iter()
.map(RoasterRecord::into_domain)
.collect())
}
async fn export_gear(&self) -> anyhow::Result<Vec<Gear>> {
let records = sqlx::query_as::<_, GearRecord>(
"SELECT id, category, make, model, created_at, updated_at FROM gear ORDER BY id",
)
.fetch_all(&self.pool)
.await
.context("failed to export gear")?;
records
.into_iter()
.map(GearRecord::into_domain)
.collect::<anyhow::Result<Vec<_>>>()
}
async fn export_roasts(&self) -> anyhow::Result<Vec<Roast>> {
let records = sqlx::query_as::<_, RoastRecord>(
"SELECT id, roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at FROM roasts ORDER BY id",
)
.fetch_all(&self.pool)
.await
.context("failed to export roasts")?;
records
.into_iter()
.map(RoastRecord::into_domain)
.collect::<anyhow::Result<Vec<_>>>()
}
async fn export_bags(&self) -> anyhow::Result<Vec<Bag>> {
let records = sqlx::query_as::<_, BagRecord>(
"SELECT id, roast_id, roast_date, amount, remaining, closed, finished_at, created_at, updated_at FROM bags ORDER BY id",
)
.fetch_all(&self.pool)
.await
.context("failed to export bags")?;
Ok(records.into_iter().map(BagRecord::into_domain).collect())
}
async fn export_brews(&self) -> anyhow::Result<Vec<Brew>> {
let records = sqlx::query_as::<_, BrewRecord>(
"SELECT id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes, brew_time, created_at, updated_at FROM brews ORDER BY id",
)
.fetch_all(&self.pool)
.await
.context("failed to export brews")?;
Ok(records.into_iter().map(BrewRecord::into_domain).collect())
}
async fn export_cafes(&self) -> anyhow::Result<Vec<Cafe>> {
let records = sqlx::query_as::<_, CafeRecord>(
"SELECT id, name, slug, city, country, latitude, longitude, website, created_at, updated_at FROM cafes ORDER BY id",
)
.fetch_all(&self.pool)
.await
.context("failed to export cafes")?;
Ok(records.into_iter().map(CafeRecord::into_domain).collect())
}
async fn export_cups(&self) -> anyhow::Result<Vec<Cup>> {
let records = sqlx::query_as::<_, CupRecord>(
"SELECT id, roast_id, cafe_id, created_at, updated_at FROM cups ORDER BY id",
)
.fetch_all(&self.pool)
.await
.context("failed to export cups")?;
Ok(records.into_iter().map(CupRecord::into_domain).collect())
}
async fn export_timeline_events(&self) -> anyhow::Result<Vec<TimelineEvent>> {
let records = sqlx::query_as::<_, TimelineEventRecord>(
"SELECT id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json FROM timeline_events ORDER BY id",
)
.fetch_all(&self.pool)
.await
.context("failed to export timeline events")?;
records
.into_iter()
.map(TimelineEventRecord::into_domain)
.collect::<anyhow::Result<Vec<_>>>()
}
// --- Restore methods ---
async fn verify_empty_database(&self) -> anyhow::Result<()> {
let tables = [
"roasters",
"roasts",
"bags",
"gear",
"brews",
"cafes",
"cups",
"timeline_events",
];
for table in tables {
let query = format!("SELECT COUNT(*) as count FROM {table}");
let row: (i64,) = sqlx::query_as(&query)
.fetch_one(&self.pool)
.await
.with_context(|| format!("failed to check table {table}"))?;
if row.0 > 0 {
bail!(
"Cannot restore: table '{table}' is not empty ({} rows). Restore requires an empty database.",
row.0
);
}
}
Ok(())
}
async fn restore_roasters(
&self,
tx: &mut DatabaseTransaction<'_>,
roasters: &[Roaster],
) -> anyhow::Result<()> {
for roaster in roasters {
sqlx::query(
"INSERT INTO roasters (id, name, slug, country, city, homepage, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(i64::from(roaster.id))
.bind(&roaster.name)
.bind(&roaster.slug)
.bind(&roaster.country)
.bind(roaster.city.as_deref())
.bind(roaster.homepage.as_deref())
.bind(roaster.created_at)
.execute(&mut **tx)
.await
.context("failed to restore roaster")?;
}
Ok(())
}
async fn restore_gear(
&self,
tx: &mut DatabaseTransaction<'_>,
gear: &[Gear],
) -> anyhow::Result<()> {
for item in gear {
sqlx::query(
"INSERT INTO gear (id, category, make, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(i64::from(item.id))
.bind(item.category.as_str())
.bind(&item.make)
.bind(&item.model)
.bind(item.created_at)
.bind(item.updated_at)
.execute(&mut **tx)
.await
.context("failed to restore gear")?;
}
Ok(())
}
async fn restore_roasts(
&self,
tx: &mut DatabaseTransaction<'_>,
roasts: &[Roast],
) -> anyhow::Result<()> {
for roast in roasts {
let tasting_notes_json = if roast.tasting_notes.is_empty() {
None
} else {
Some(
to_string(&roast.tasting_notes)
.context("failed to encode tasting notes for restore")?,
)
};
sqlx::query(
"INSERT INTO roasts (id, roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(i64::from(roast.id))
.bind(i64::from(roast.roaster_id))
.bind(&roast.name)
.bind(&roast.slug)
.bind(roast.origin.as_deref())
.bind(roast.region.as_deref())
.bind(roast.producer.as_deref())
.bind(roast.process.as_deref())
.bind(tasting_notes_json.as_deref())
.bind(roast.created_at)
.execute(&mut **tx)
.await
.context("failed to restore roast")?;
}
Ok(())
}
async fn restore_bags(
&self,
tx: &mut DatabaseTransaction<'_>,
bags: &[Bag],
) -> anyhow::Result<()> {
for bag in bags {
sqlx::query(
"INSERT INTO bags (id, roast_id, roast_date, amount, remaining, closed, finished_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(i64::from(bag.id))
.bind(i64::from(bag.roast_id))
.bind(bag.roast_date)
.bind(bag.amount)
.bind(bag.remaining)
.bind(bag.closed)
.bind(bag.finished_at)
.bind(bag.created_at)
.bind(bag.updated_at)
.execute(&mut **tx)
.await
.context("failed to restore bag")?;
}
Ok(())
}
async fn restore_brews(
&self,
tx: &mut DatabaseTransaction<'_>,
brews: &[Brew],
) -> anyhow::Result<()> {
for brew in brews {
let quick_notes_json = if brew.quick_notes.is_empty() {
None
} else {
let values: Vec<&str> = brew.quick_notes.iter().map(|n| n.form_value()).collect();
Some(to_string(&values).context("failed to encode quick notes for restore")?)
};
sqlx::query(
"INSERT INTO brews (id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes, brew_time, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(i64::from(brew.id))
.bind(i64::from(brew.bag_id))
.bind(brew.coffee_weight)
.bind(i64::from(brew.grinder_id))
.bind(brew.grind_setting)
.bind(i64::from(brew.brewer_id))
.bind(brew.filter_paper_id.map(i64::from))
.bind(brew.water_volume)
.bind(brew.water_temp)
.bind(quick_notes_json.as_deref())
.bind(brew.brew_time)
.bind(brew.created_at)
.bind(brew.updated_at)
.execute(&mut **tx)
.await
.context("failed to restore brew")?;
}
Ok(())
}
async fn restore_cafes(
&self,
tx: &mut DatabaseTransaction<'_>,
cafes: &[Cafe],
) -> anyhow::Result<()> {
for cafe in cafes {
sqlx::query(
"INSERT INTO cafes (id, name, slug, city, country, latitude, longitude, website, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(i64::from(cafe.id))
.bind(&cafe.name)
.bind(&cafe.slug)
.bind(&cafe.city)
.bind(&cafe.country)
.bind(cafe.latitude)
.bind(cafe.longitude)
.bind(cafe.website.as_deref())
.bind(cafe.created_at)
.bind(cafe.updated_at)
.execute(&mut **tx)
.await
.context("failed to restore cafe")?;
}
Ok(())
}
async fn restore_cups(
&self,
tx: &mut DatabaseTransaction<'_>,
cups: &[Cup],
) -> anyhow::Result<()> {
for cup in cups {
sqlx::query(
"INSERT INTO cups (id, roast_id, cafe_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(i64::from(cup.id))
.bind(i64::from(cup.roast_id))
.bind(i64::from(cup.cafe_id))
.bind(cup.created_at)
.bind(cup.updated_at)
.execute(&mut **tx)
.await
.context("failed to restore cup")?;
}
Ok(())
}
async fn restore_timeline_events(
&self,
tx: &mut DatabaseTransaction<'_>,
events: &[TimelineEvent],
) -> anyhow::Result<()> {
for event in events {
let details_json = to_string(&event.details)
.context("failed to encode timeline event details for restore")?;
let tasting_notes_json = to_string(&event.tasting_notes)
.context("failed to encode timeline event tasting notes for restore")?;
let brew_data_json = event
.brew_data
.as_ref()
.map(to_string)
.transpose()
.context("failed to encode timeline brew data for restore")?;
sqlx::query(
"INSERT INTO timeline_events (id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(i64::from(event.id))
.bind(&event.entity_type)
.bind(event.entity_id)
.bind(&event.action)
.bind(event.occurred_at)
.bind(&event.title)
.bind(&details_json)
.bind(&tasting_notes_json)
.bind(event.slug.as_deref())
.bind(event.roaster_slug.as_deref())
.bind(brew_data_json.as_deref())
.execute(&mut **tx)
.await
.context("failed to restore timeline event")?;
}
Ok(())
}
}
// --- Record types for export queries ---
#[derive(sqlx::FromRow)]
struct RoasterRecord {
id: i64,
name: String,
slug: String,
country: String,
city: Option<String>,
homepage: Option<String>,
created_at: DateTime<Utc>,
}
impl RoasterRecord {
fn into_domain(self) -> Roaster {
Roaster {
id: RoasterId::from(self.id),
name: self.name,
slug: self.slug,
country: self.country,
city: self.city,
homepage: self.homepage,
created_at: self.created_at,
}
}
}
#[derive(sqlx::FromRow)]
struct GearRecord {
id: i64,
category: String,
make: String,
model: String,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl GearRecord {
fn into_domain(self) -> anyhow::Result<Gear> {
let category = GearCategory::from_str(&self.category)
.map_err(|()| anyhow::anyhow!("invalid gear category: {}", self.category))?;
Ok(Gear {
id: GearId::new(self.id),
category,
make: self.make,
model: self.model,
created_at: self.created_at,
updated_at: self.updated_at,
})
}
}
#[derive(sqlx::FromRow)]
struct RoastRecord {
id: i64,
roaster_id: i64,
name: String,
slug: String,
origin: Option<String>,
region: Option<String>,
producer: Option<String>,
process: Option<String>,
tasting_notes: Option<String>,
created_at: DateTime<Utc>,
}
impl RoastRecord {
fn into_domain(self) -> anyhow::Result<Roast> {
let tasting_notes = decode_json_vec(self.tasting_notes, "tasting notes")?;
Ok(Roast {
id: RoastId::from(self.id),
roaster_id: RoasterId::from(self.roaster_id),
name: self.name,
slug: self.slug,
origin: self.origin,
region: self.region,
producer: self.producer,
process: self.process,
tasting_notes,
created_at: self.created_at,
})
}
}
#[derive(sqlx::FromRow)]
struct BagRecord {
id: i64,
roast_id: i64,
roast_date: Option<NaiveDate>,
amount: f64,
remaining: f64,
closed: bool,
finished_at: Option<NaiveDate>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl BagRecord {
fn into_domain(self) -> Bag {
Bag {
id: BagId::new(self.id),
roast_id: RoastId::new(self.roast_id),
roast_date: self.roast_date,
amount: self.amount,
remaining: self.remaining,
closed: self.closed,
finished_at: self.finished_at,
created_at: self.created_at,
updated_at: self.updated_at,
}
}
}
#[derive(sqlx::FromRow)]
struct BrewRecord {
id: i64,
bag_id: i64,
coffee_weight: f64,
grinder_id: i64,
grind_setting: f64,
brewer_id: i64,
filter_paper_id: Option<i64>,
water_volume: i32,
water_temp: f64,
quick_notes: Option<String>,
brew_time: Option<i32>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl BrewRecord {
fn into_domain(self) -> Brew {
let quick_notes = match self.quick_notes {
Some(s) if !s.is_empty() => serde_json::from_str::<Vec<String>>(&s)
.unwrap_or_default()
.iter()
.filter_map(|v| QuickNote::from_str_value(v))
.collect(),
_ => Vec::new(),
};
Brew {
id: BrewId::new(self.id),
bag_id: BagId::new(self.bag_id),
coffee_weight: self.coffee_weight,
grinder_id: GearId::new(self.grinder_id),
grind_setting: self.grind_setting,
brewer_id: GearId::new(self.brewer_id),
filter_paper_id: self.filter_paper_id.map(GearId::new),
water_volume: self.water_volume,
water_temp: self.water_temp,
quick_notes,
brew_time: self.brew_time,
created_at: self.created_at,
updated_at: self.updated_at,
}
}
}
#[derive(sqlx::FromRow)]
struct CafeRecord {
id: i64,
name: String,
slug: String,
city: String,
country: String,
latitude: f64,
longitude: f64,
website: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl CafeRecord {
fn into_domain(self) -> Cafe {
Cafe {
id: CafeId::from(self.id),
name: self.name,
slug: self.slug,
city: self.city,
country: self.country,
latitude: self.latitude,
longitude: self.longitude,
website: self.website,
created_at: self.created_at,
updated_at: self.updated_at,
}
}
}
#[derive(sqlx::FromRow)]
struct CupRecord {
id: i64,
roast_id: i64,
cafe_id: i64,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl CupRecord {
fn into_domain(self) -> Cup {
Cup {
id: CupId::from(self.id),
roast_id: RoastId::from(self.roast_id),
cafe_id: CafeId::from(self.cafe_id),
created_at: self.created_at,
updated_at: self.updated_at,
}
}
}
#[derive(sqlx::FromRow)]
struct TimelineEventRecord {
id: i64,
entity_type: String,
entity_id: i64,
action: String,
occurred_at: DateTime<Utc>,
title: String,
details_json: Option<String>,
tasting_notes_json: Option<String>,
slug: Option<String>,
roaster_slug: Option<String>,
brew_data_json: Option<String>,
}
impl TimelineEventRecord {
fn into_domain(self) -> anyhow::Result<TimelineEvent> {
let details = decode_json_vec(self.details_json, "timeline event details")?;
let tasting_notes = decode_json_vec(self.tasting_notes_json, "timeline tasting notes")?;
let brew_data = decode_json_opt(self.brew_data_json, "timeline brew data")?;
Ok(TimelineEvent {
id: TimelineEventId::from(self.id),
entity_type: self.entity_type,
entity_id: self.entity_id,
action: self.action,
occurred_at: self.occurred_at,
title: self.title,
details,
tasting_notes,
slug: self.slug,
roaster_slug: self.roaster_slug,
brew_data,
})
}
}