feat(backup): add CLI backup and restore commands
- Add BackupData struct and BackupService with raw SQL export/import - Restore uses raw inserts to bypass brew deductions and timeline creation - Restore requires an empty database, inserts in FK dependency order - Add comprehensive e2e test verifying full round-trip fidelity
This commit is contained in:
parent
cb1cdc5c5b
commit
2ac61345ec
8 changed files with 1040 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,3 +3,4 @@ result*
|
|||
brewlog.db
|
||||
TODO.md
|
||||
SPEC.md
|
||||
backup.json
|
||||
575
src/infrastructure/backup.rs
Normal file
575
src/infrastructure/backup.rs
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
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;
|
||||
use crate::domain::gear::{Gear, GearCategory};
|
||||
use crate::domain::ids::{BagId, BrewId, GearId, RoastId, RoasterId, TimelineEventId};
|
||||
use crate::domain::roasters::Roaster;
|
||||
use crate::domain::roasts::Roast;
|
||||
use crate::domain::timeline::{TimelineBrewData, TimelineEvent, TimelineEventDetail};
|
||||
use crate::infrastructure::database::{DatabasePool, DatabaseTransaction};
|
||||
|
||||
#[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>,
|
||||
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 timeline_events = self.export_timeline_events().await?;
|
||||
|
||||
Ok(BackupData {
|
||||
version: 1,
|
||||
created_at: Utc::now(),
|
||||
roasters,
|
||||
gear,
|
||||
roasts,
|
||||
bags,
|
||||
brews,
|
||||
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_timeline_events(&mut tx, &data.timeline_events)
|
||||
.await?;
|
||||
|
||||
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, notes, 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, 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_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",
|
||||
"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, notes, 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.notes.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 {
|
||||
sqlx::query(
|
||||
"INSERT INTO brews (id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, 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(brew.created_at)
|
||||
.bind(brew.updated_at)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.context("failed to restore brew")?;
|
||||
}
|
||||
|
||||
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>,
|
||||
notes: 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,
|
||||
notes: self.notes,
|
||||
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 = match self.tasting_notes {
|
||||
Some(raw) => from_str::<Vec<String>>(&raw)
|
||||
.with_context(|| format!("failed to decode tasting notes: {raw}"))?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
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,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl BrewRecord {
|
||||
fn into_domain(self) -> Brew {
|
||||
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,
|
||||
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 = match self.details_json {
|
||||
Some(raw) if !raw.is_empty() => from_str::<Vec<TimelineEventDetail>>(&raw)
|
||||
.with_context(|| format!("failed to decode timeline event details: {raw}"))?,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
let tasting_notes = match self.tasting_notes_json {
|
||||
Some(raw) if !raw.is_empty() => from_str::<Vec<String>>(&raw)
|
||||
.with_context(|| format!("failed to decode timeline tasting notes: {raw}"))?,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
let brew_data = match self.brew_data_json {
|
||||
Some(raw) if !raw.is_empty() => Some(
|
||||
from_str::<TimelineBrewData>(&raw)
|
||||
.with_context(|| format!("failed to decode timeline brew data: {raw}"))?,
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod client;
|
||||
pub mod database;
|
||||
pub mod repositories;
|
||||
|
|
|
|||
19
src/main.rs
19
src/main.rs
|
|
@ -1,6 +1,8 @@
|
|||
use anyhow::Result;
|
||||
use brewlog::application::{ServerConfig, serve};
|
||||
use brewlog::infrastructure::backup::{BackupData, BackupService};
|
||||
use brewlog::infrastructure::client::BrewlogClient;
|
||||
use brewlog::infrastructure::database::Database;
|
||||
use brewlog::presentation::cli::{
|
||||
Cli, Commands, ServeCommand, bags, brews, gear, roasters, roasts, tokens,
|
||||
};
|
||||
|
|
@ -45,6 +47,23 @@ async fn main() -> Result<()> {
|
|||
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
||||
tokens::run(&client, command).await
|
||||
}
|
||||
Commands::Backup(cmd) => {
|
||||
let database = Database::connect(&cmd.database_url).await?;
|
||||
let service = BackupService::new(database.clone_pool());
|
||||
let data = service.export().await?;
|
||||
let json = serde_json::to_string_pretty(&data)?;
|
||||
println!("{json}");
|
||||
Ok(())
|
||||
}
|
||||
Commands::Restore(cmd) => {
|
||||
let contents = std::fs::read_to_string(&cmd.file)?;
|
||||
let data: BackupData = serde_json::from_str(&contents)?;
|
||||
let database = Database::connect(&cmd.database_url).await?;
|
||||
let service = BackupService::new(database.clone_pool());
|
||||
service.restore(data).await?;
|
||||
eprintln!("Restore complete.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
27
src/presentation/cli/backup.rs
Normal file
27
src/presentation/cli/backup.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
use clap::Args;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct BackupCommand {
|
||||
/// Database URL to back up from
|
||||
#[arg(
|
||||
long,
|
||||
env = "BREWLOG_DATABASE_URL",
|
||||
default_value = "sqlite://brewlog.db"
|
||||
)]
|
||||
pub database_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct RestoreCommand {
|
||||
/// Database URL to restore into (must be an empty database)
|
||||
#[arg(
|
||||
long,
|
||||
env = "BREWLOG_DATABASE_URL",
|
||||
default_value = "sqlite://brewlog.db"
|
||||
)]
|
||||
pub database_url: String,
|
||||
|
||||
/// Path to the backup JSON file
|
||||
#[arg(long)]
|
||||
pub file: String,
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod backup;
|
||||
pub mod bags;
|
||||
pub mod brews;
|
||||
pub mod gear;
|
||||
|
|
@ -8,6 +9,7 @@ pub mod tokens;
|
|||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use backup::{BackupCommand, RestoreCommand};
|
||||
use bags::BagCommands;
|
||||
use brews::BrewCommands;
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
|
|
@ -71,6 +73,12 @@ pub enum Commands {
|
|||
#[command(subcommand)]
|
||||
command: TokenCommands,
|
||||
},
|
||||
|
||||
/// Back up all coffee data to JSON (stdout)
|
||||
Backup(BackupCommand),
|
||||
|
||||
/// Restore coffee data from a JSON backup file
|
||||
Restore(RestoreCommand),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
|
|
|
|||
408
tests/server/backup.rs
Normal file
408
tests/server/backup.rs
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use brewlog::domain::bags::{Bag, BagFilter, BagSortKey, NewBag};
|
||||
use brewlog::domain::brews::{Brew, BrewFilter, BrewSortKey, NewBrew};
|
||||
use brewlog::domain::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear};
|
||||
use brewlog::domain::listing::{ListRequest, PageSize};
|
||||
use brewlog::domain::repositories::{
|
||||
BagRepository, BrewRepository, GearRepository, RoastRepository, RoasterRepository,
|
||||
TimelineEventRepository,
|
||||
};
|
||||
use brewlog::domain::roasters::{NewRoaster, Roaster, RoasterSortKey};
|
||||
use brewlog::domain::roasts::{NewRoast, Roast, RoastSortKey};
|
||||
use brewlog::domain::timeline::TimelineEvent;
|
||||
use brewlog::infrastructure::backup::{BackupData, BackupService};
|
||||
use brewlog::infrastructure::database::Database;
|
||||
use brewlog::infrastructure::repositories::bags::SqlBagRepository;
|
||||
use brewlog::infrastructure::repositories::brews::SqlBrewRepository;
|
||||
use brewlog::infrastructure::repositories::gear::SqlGearRepository;
|
||||
use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository;
|
||||
use brewlog::infrastructure::repositories::roasts::SqlRoastRepository;
|
||||
use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepository;
|
||||
|
||||
struct TestDb {
|
||||
roaster_repo: Arc<dyn RoasterRepository>,
|
||||
roast_repo: Arc<dyn RoastRepository>,
|
||||
bag_repo: Arc<dyn BagRepository>,
|
||||
gear_repo: Arc<dyn GearRepository>,
|
||||
brew_repo: Arc<dyn BrewRepository>,
|
||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
backup_service: BackupService,
|
||||
}
|
||||
|
||||
async fn create_test_db() -> TestDb {
|
||||
let database = Database::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("Failed to connect to in-memory database");
|
||||
|
||||
let pool = database.clone_pool();
|
||||
|
||||
TestDb {
|
||||
roaster_repo: Arc::new(SqlRoasterRepository::new(pool.clone())),
|
||||
roast_repo: Arc::new(SqlRoastRepository::new(pool.clone())),
|
||||
bag_repo: Arc::new(SqlBagRepository::new(pool.clone())),
|
||||
gear_repo: Arc::new(SqlGearRepository::new(pool.clone())),
|
||||
brew_repo: Arc::new(SqlBrewRepository::new(pool.clone())),
|
||||
timeline_repo: Arc::new(SqlTimelineEventRepository::new(pool.clone())),
|
||||
backup_service: BackupService::new(pool),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_all_request<K: brewlog::domain::listing::SortKey>() -> ListRequest<K> {
|
||||
ListRequest::new(
|
||||
1,
|
||||
PageSize::All,
|
||||
K::default(),
|
||||
K::default().default_direction(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_all_roasters(repo: &dyn RoasterRepository) -> Vec<Roaster> {
|
||||
repo.list(&list_all_request::<RoasterSortKey>())
|
||||
.await
|
||||
.expect("failed to list roasters")
|
||||
.items
|
||||
}
|
||||
|
||||
async fn list_all_roasts(repo: &dyn RoastRepository) -> Vec<Roast> {
|
||||
let page = repo
|
||||
.list(&list_all_request::<RoastSortKey>())
|
||||
.await
|
||||
.expect("failed to list roasts");
|
||||
page.items.into_iter().map(|rwr| rwr.roast).collect()
|
||||
}
|
||||
|
||||
async fn list_all_bags(repo: &dyn BagRepository) -> Vec<Bag> {
|
||||
let page = repo
|
||||
.list(BagFilter::all(), &list_all_request::<BagSortKey>())
|
||||
.await
|
||||
.expect("failed to list bags");
|
||||
page.items.into_iter().map(|bwr| bwr.bag).collect()
|
||||
}
|
||||
|
||||
async fn list_all_gear(repo: &dyn GearRepository) -> Vec<Gear> {
|
||||
repo.list(GearFilter::all(), &list_all_request::<GearSortKey>())
|
||||
.await
|
||||
.expect("failed to list gear")
|
||||
.items
|
||||
}
|
||||
|
||||
async fn list_all_brews(repo: &dyn BrewRepository) -> Vec<Brew> {
|
||||
let page = repo
|
||||
.list(BrewFilter::all(), &list_all_request::<BrewSortKey>())
|
||||
.await
|
||||
.expect("failed to list brews");
|
||||
page.items.into_iter().map(|bwd| bwd.brew).collect()
|
||||
}
|
||||
|
||||
async fn list_all_timeline_events(repo: &dyn TimelineEventRepository) -> Vec<TimelineEvent> {
|
||||
repo.list_all()
|
||||
.await
|
||||
.expect("failed to list timeline events")
|
||||
}
|
||||
|
||||
/// Populate a database with representative test data and return the key entities.
|
||||
async fn populate_test_data(db: &TestDb) -> (Roaster, Roast, Bag, Gear, Gear, Gear, Brew) {
|
||||
// Create roaster
|
||||
let roaster = db
|
||||
.roaster_repo
|
||||
.insert(NewRoaster {
|
||||
name: "Square Mile".to_string(),
|
||||
country: "UK".to_string(),
|
||||
city: Some("London".to_string()),
|
||||
homepage: Some("https://shop.squaremilecoffee.com".to_string()),
|
||||
notes: Some("Great seasonal espresso".to_string()),
|
||||
})
|
||||
.await
|
||||
.expect("failed to create roaster");
|
||||
|
||||
// Create roast
|
||||
let roast = db
|
||||
.roast_repo
|
||||
.insert(NewRoast {
|
||||
roaster_id: roaster.id,
|
||||
name: "Red Brick".to_string(),
|
||||
origin: "Brazil".to_string(),
|
||||
region: "Cerrado".to_string(),
|
||||
producer: "Fazenda Passeio".to_string(),
|
||||
tasting_notes: vec![
|
||||
"Milk Chocolate".to_string(),
|
||||
"Hazelnut".to_string(),
|
||||
"Caramel".to_string(),
|
||||
],
|
||||
process: "Natural".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("failed to create roast");
|
||||
|
||||
// Create bag (250g)
|
||||
let bag = db
|
||||
.bag_repo
|
||||
.insert(NewBag {
|
||||
roast_id: roast.id,
|
||||
roast_date: Some(chrono::NaiveDate::from_ymd_opt(2025, 1, 15).unwrap()),
|
||||
amount: 250.0,
|
||||
})
|
||||
.await
|
||||
.expect("failed to create bag");
|
||||
|
||||
// Create gear
|
||||
let grinder = db
|
||||
.gear_repo
|
||||
.insert(NewGear {
|
||||
category: GearCategory::Grinder,
|
||||
make: "Comandante".to_string(),
|
||||
model: "C40 MK4".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("failed to create grinder");
|
||||
|
||||
let brewer = db
|
||||
.gear_repo
|
||||
.insert(NewGear {
|
||||
category: GearCategory::Brewer,
|
||||
make: "Hario".to_string(),
|
||||
model: "V60 02".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("failed to create brewer");
|
||||
|
||||
let filter_paper = db
|
||||
.gear_repo
|
||||
.insert(NewGear {
|
||||
category: GearCategory::FilterPaper,
|
||||
make: "Hario".to_string(),
|
||||
model: "V60 Tabbed 02".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("failed to create filter paper");
|
||||
|
||||
// Create a brew (deducts 15g from bag, remaining becomes 235)
|
||||
let brew = db
|
||||
.brew_repo
|
||||
.insert(NewBrew {
|
||||
bag_id: bag.id,
|
||||
coffee_weight: 15.0,
|
||||
grinder_id: grinder.id,
|
||||
grind_setting: 24.0,
|
||||
brewer_id: brewer.id,
|
||||
filter_paper_id: Some(filter_paper.id),
|
||||
water_volume: 250,
|
||||
water_temp: 93.5,
|
||||
})
|
||||
.await
|
||||
.expect("failed to create brew");
|
||||
|
||||
// Re-fetch bag to get updated remaining
|
||||
let bag = db
|
||||
.bag_repo
|
||||
.get(bag.id)
|
||||
.await
|
||||
.expect("failed to re-fetch bag");
|
||||
|
||||
assert_eq!(
|
||||
bag.remaining, 235.0,
|
||||
"bag remaining should be 235 after brew"
|
||||
);
|
||||
|
||||
(roaster, roast, bag, grinder, brewer, filter_paper, brew)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_and_restore_round_trip() {
|
||||
// 1. Create source database and populate with test data
|
||||
let source = create_test_db().await;
|
||||
let (roaster, roast, bag, grinder, brewer, filter_paper, brew) =
|
||||
populate_test_data(&source).await;
|
||||
|
||||
// Verify timeline events were created (roaster + roast inserts create them)
|
||||
let source_timeline = list_all_timeline_events(source.timeline_repo.as_ref()).await;
|
||||
assert!(
|
||||
source_timeline.len() >= 2,
|
||||
"expected at least 2 timeline events from roaster+roast creation"
|
||||
);
|
||||
|
||||
// 2. Export backup
|
||||
let backup_data = source
|
||||
.backup_service
|
||||
.export()
|
||||
.await
|
||||
.expect("failed to export backup");
|
||||
|
||||
assert_eq!(backup_data.version, 1);
|
||||
assert_eq!(backup_data.roasters.len(), 1);
|
||||
assert_eq!(backup_data.roasts.len(), 1);
|
||||
assert_eq!(backup_data.bags.len(), 1);
|
||||
assert_eq!(backup_data.gear.len(), 3);
|
||||
assert_eq!(backup_data.brews.len(), 1);
|
||||
assert_eq!(backup_data.timeline_events.len(), source_timeline.len());
|
||||
|
||||
// 3. Serialize to JSON and deserialize back (verify serde round-trip)
|
||||
let json = serde_json::to_string_pretty(&backup_data).expect("failed to serialize backup");
|
||||
let restored_data: BackupData =
|
||||
serde_json::from_str(&json).expect("failed to deserialize backup");
|
||||
|
||||
assert_eq!(restored_data.version, 1);
|
||||
assert_eq!(restored_data.roasters.len(), 1);
|
||||
assert_eq!(restored_data.roasts.len(), 1);
|
||||
assert_eq!(restored_data.bags.len(), 1);
|
||||
assert_eq!(restored_data.gear.len(), 3);
|
||||
assert_eq!(restored_data.brews.len(), 1);
|
||||
|
||||
// 4. Restore to a fresh database
|
||||
let target = create_test_db().await;
|
||||
target
|
||||
.backup_service
|
||||
.restore(restored_data)
|
||||
.await
|
||||
.expect("failed to restore backup");
|
||||
|
||||
// 5. Verify all data matches
|
||||
|
||||
// Roasters
|
||||
let target_roasters = list_all_roasters(target.roaster_repo.as_ref()).await;
|
||||
assert_eq!(target_roasters.len(), 1);
|
||||
let restored_roaster = &target_roasters[0];
|
||||
assert_eq!(restored_roaster.id, roaster.id);
|
||||
assert_eq!(restored_roaster.name, roaster.name);
|
||||
assert_eq!(restored_roaster.slug, roaster.slug);
|
||||
assert_eq!(restored_roaster.country, roaster.country);
|
||||
assert_eq!(restored_roaster.city, roaster.city);
|
||||
assert_eq!(restored_roaster.homepage, roaster.homepage);
|
||||
assert_eq!(restored_roaster.notes, roaster.notes);
|
||||
assert_eq!(restored_roaster.created_at, roaster.created_at);
|
||||
|
||||
// Roasts
|
||||
let target_roasts = list_all_roasts(target.roast_repo.as_ref()).await;
|
||||
assert_eq!(target_roasts.len(), 1);
|
||||
let restored_roast = &target_roasts[0];
|
||||
assert_eq!(restored_roast.id, roast.id);
|
||||
assert_eq!(restored_roast.roaster_id, roast.roaster_id);
|
||||
assert_eq!(restored_roast.name, roast.name);
|
||||
assert_eq!(restored_roast.slug, roast.slug);
|
||||
assert_eq!(restored_roast.origin, roast.origin);
|
||||
assert_eq!(restored_roast.region, roast.region);
|
||||
assert_eq!(restored_roast.producer, roast.producer);
|
||||
assert_eq!(restored_roast.process, roast.process);
|
||||
assert_eq!(restored_roast.tasting_notes, roast.tasting_notes);
|
||||
|
||||
// Bags - critically verify remaining was NOT re-deducted
|
||||
let target_bags = list_all_bags(target.bag_repo.as_ref()).await;
|
||||
assert_eq!(target_bags.len(), 1);
|
||||
let restored_bag = &target_bags[0];
|
||||
assert_eq!(restored_bag.id, bag.id);
|
||||
assert_eq!(restored_bag.roast_id, bag.roast_id);
|
||||
assert_eq!(restored_bag.roast_date, bag.roast_date);
|
||||
assert_eq!(restored_bag.amount, 250.0);
|
||||
assert_eq!(
|
||||
restored_bag.remaining, 235.0,
|
||||
"bag remaining should be preserved at 235, not re-deducted by brew restore"
|
||||
);
|
||||
assert_eq!(restored_bag.closed, bag.closed);
|
||||
|
||||
// Gear
|
||||
let target_gear = list_all_gear(target.gear_repo.as_ref()).await;
|
||||
assert_eq!(target_gear.len(), 3);
|
||||
let grinder_restored = target_gear.iter().find(|g| g.id == grinder.id).unwrap();
|
||||
assert_eq!(grinder_restored.category, GearCategory::Grinder);
|
||||
assert_eq!(grinder_restored.make, "Comandante");
|
||||
assert_eq!(grinder_restored.model, "C40 MK4");
|
||||
let brewer_restored = target_gear.iter().find(|g| g.id == brewer.id).unwrap();
|
||||
assert_eq!(brewer_restored.category, GearCategory::Brewer);
|
||||
let fp_restored = target_gear
|
||||
.iter()
|
||||
.find(|g| g.id == filter_paper.id)
|
||||
.unwrap();
|
||||
assert_eq!(fp_restored.category, GearCategory::FilterPaper);
|
||||
|
||||
// Brews
|
||||
let target_brews = list_all_brews(target.brew_repo.as_ref()).await;
|
||||
assert_eq!(target_brews.len(), 1);
|
||||
let restored_brew = &target_brews[0];
|
||||
assert_eq!(restored_brew.id, brew.id);
|
||||
assert_eq!(restored_brew.bag_id, brew.bag_id);
|
||||
assert_eq!(restored_brew.coffee_weight, 15.0);
|
||||
assert_eq!(restored_brew.grinder_id, brew.grinder_id);
|
||||
assert_eq!(restored_brew.grind_setting, 24.0);
|
||||
assert_eq!(restored_brew.brewer_id, brew.brewer_id);
|
||||
assert_eq!(restored_brew.filter_paper_id, Some(filter_paper.id));
|
||||
assert_eq!(restored_brew.water_volume, 250);
|
||||
assert_eq!(restored_brew.water_temp, 93.5);
|
||||
|
||||
// Timeline events
|
||||
let target_timeline = list_all_timeline_events(target.timeline_repo.as_ref()).await;
|
||||
assert_eq!(target_timeline.len(), source_timeline.len());
|
||||
for (source_event, target_event) in source_timeline.iter().zip(target_timeline.iter()) {
|
||||
assert_eq!(target_event.id, source_event.id);
|
||||
assert_eq!(target_event.entity_type, source_event.entity_type);
|
||||
assert_eq!(target_event.entity_id, source_event.entity_id);
|
||||
assert_eq!(target_event.action, source_event.action);
|
||||
assert_eq!(target_event.title, source_event.title);
|
||||
assert_eq!(target_event.details.len(), source_event.details.len());
|
||||
assert_eq!(target_event.tasting_notes, source_event.tasting_notes);
|
||||
assert_eq!(target_event.slug, source_event.slug);
|
||||
assert_eq!(target_event.roaster_slug, source_event.roaster_slug);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_to_non_empty_database_fails() {
|
||||
let db = create_test_db().await;
|
||||
|
||||
// Add a roaster to make the database non-empty
|
||||
db.roaster_repo
|
||||
.insert(NewRoaster {
|
||||
name: "Existing Roaster".to_string(),
|
||||
country: "UK".to_string(),
|
||||
city: None,
|
||||
homepage: None,
|
||||
notes: None,
|
||||
})
|
||||
.await
|
||||
.expect("failed to create roaster");
|
||||
|
||||
// Create a minimal backup
|
||||
let backup_data = BackupData {
|
||||
version: 1,
|
||||
created_at: chrono::Utc::now(),
|
||||
roasters: vec![],
|
||||
gear: vec![],
|
||||
roasts: vec![],
|
||||
bags: vec![],
|
||||
brews: vec![],
|
||||
timeline_events: vec![],
|
||||
};
|
||||
|
||||
// Restore should fail because the database is not empty
|
||||
let result = db.backup_service.restore(backup_data).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("not empty"),
|
||||
"expected 'not empty' error, got: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_empty_database() {
|
||||
let db = create_test_db().await;
|
||||
|
||||
let backup_data = db
|
||||
.backup_service
|
||||
.export()
|
||||
.await
|
||||
.expect("failed to export empty database");
|
||||
|
||||
assert_eq!(backup_data.version, 1);
|
||||
assert!(backup_data.roasters.is_empty());
|
||||
assert!(backup_data.roasts.is_empty());
|
||||
assert!(backup_data.bags.is_empty());
|
||||
assert!(backup_data.gear.is_empty());
|
||||
assert!(backup_data.brews.is_empty());
|
||||
assert!(backup_data.timeline_events.is_empty());
|
||||
|
||||
// Should serialize to valid JSON
|
||||
let json = serde_json::to_string_pretty(&backup_data).expect("failed to serialize");
|
||||
let parsed: BackupData = serde_json::from_str(&json).expect("failed to deserialize");
|
||||
assert_eq!(parsed.version, 1);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod auth_api;
|
||||
pub mod backup;
|
||||
pub mod bags_api;
|
||||
pub mod brews_api;
|
||||
pub mod datastar;
|
||||
|
|
|
|||
Loading…
Reference in a new issue