From b308e1f63432f6ecdcd58c9b552d9ce2bad2fe6d Mon Sep 17 00:00:00 2001 From: utof Date: Thu, 30 Apr 2026 22:01:28 +0400 Subject: [PATCH 01/11] feat(core): CoreError::BackupFailed + BackupFailureReason + DatabaseAdmin port WHY new typed CoreError variant: backup failures need user-actionable UX (target-exists vs disk-full vs permissions). Reusing CoreError::Io would collapse the four user-meaningful causes into one undifferentiated message. WHY new DatabaseAdmin port (not method on FileRepository): backup is database-engine-shaped, not file-row-shaped. Same logic applies to vacuum, integrity_check, etc. Slice 2 (vault.toml writer) and slice 3 (restore) of #168 will grow this trait. 5 wire-shape tests in serialize_shape.rs lock down the JSON contract the frontend pattern-matches on (kind="BackupFailed", data.reason.kind). Refs #168. --- crates/core/src/errors.rs | 71 +++++++++++++++++++++++++ crates/core/src/ports/database_admin.rs | 34 ++++++++++++ crates/core/src/ports/mod.rs | 2 + crates/core/tests/serialize_shape.rs | 70 ++++++++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 crates/core/src/ports/database_admin.rs diff --git a/crates/core/src/errors.rs b/crates/core/src/errors.rs index 3f6faeb..5110b29 100644 --- a/crates/core/src/errors.rs +++ b/crates/core/src/errors.rs @@ -76,6 +76,24 @@ pub enum CoreError { /// The specific reason the full hash is not available. reason: FullHashUnavailableReason, }, + + /// Database backup failed. The `reason` enum carries a typed cause; + /// frontend pattern-matches on `reason.kind` for targeted UX. + /// + /// WHY a dedicated variant (not reusing `Io`): backup failures need + /// targeted UX. `TargetExists` says "pass --force"; `Io` would say + /// "something went wrong" — much worse. + /// + /// WHY `{reason}` not `{reason:?}` in `#[error]`: `BackupFailureReason` + /// itself derives `thiserror::Error`, so its per-variant `#[error]` + /// produces the human-friendly message via Display. Debug printing + /// (`{reason:?}`) would emit `TargetExists { path: "..." }` which is + /// uglier and not user-facing. + #[error("backup failed: {reason}")] + BackupFailed { + /// The specific reason the backup failed. + reason: BackupFailureReason, + }, } /// Why a `full_hash` could not be produced. @@ -121,3 +139,56 @@ impl From for CoreError { } } } + +/// Typed reasons a `BackupDatabaseUseCase::execute` call can fail. +/// +/// WHY `path: String` (not `PathBuf`): specta serialises both as TS +/// `string`, but `String` avoids the `PathBuf -> Display -> serialize` +/// step where a non-UTF8 path would become `""`. +/// Backups are user-facing artifacts; we only care about display, and +/// `String` is the cleaner type at the IPC seam. +#[derive(thiserror::Error, Debug, Clone, serde::Serialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(tag = "kind", content = "data")] +pub enum BackupFailureReason { + /// `--to ` was supplied AND the target already exists, AND + /// `--force` was not passed. + #[error("target already exists: {path}; pass --force to overwrite")] + TargetExists { + /// The path that already exists. + path: String, + }, + + /// Filesystem refused the write — permissions, read-only mount, + /// missing parent dir we couldn't create, broken symlink whose + /// target is gone, etc. + #[error("target unwritable: {path}: {message}")] + TargetUnwritable { + /// The path that could not be written. + path: String, + /// The underlying error message. + message: String, + }, + + /// Disk full / out of space. + #[error("disk full while writing {path}")] + DiskFull { + /// The path being written when the disk filled. + path: String, + }, + + /// A backup is already running on this `BackupDatabaseUseCase` instance; + /// concurrent attempts are refused. Try again when the in-flight backup + /// completes. + #[error("a backup is already in progress")] + AlreadyInProgress, + + /// Wrapped `SQLite` or unexpected-state error message that doesn't fit + /// any of the typed buckets above. + /// + /// WHY a catch-all: `SQLite` returns dozens of distinct error codes; + /// only the four above are user-actionable in slice 1. Future + /// telemetry can mine `Internal(...)` messages to add typed reasons. + #[error("internal backup error: {0}")] + Internal(String), +} diff --git a/crates/core/src/ports/database_admin.rs b/crates/core/src/ports/database_admin.rs new file mode 100644 index 0000000..a51cf9a --- /dev/null +++ b/crates/core/src/ports/database_admin.rs @@ -0,0 +1,34 @@ +//! Database-engine-level administration port (slice 1: backup). +//! +//! WHY a new port (not a method on `FileRepository`): every method on +//! `FileRepository` operates on file-row shape (`upsert_file`, +//! `lookup_by_file_uuid`, etc.). A backup operates at the database-engine +//! level, not the row level — adding it to `FileRepository` is a category +//! mismatch. The same logic would put `vacuum`, `analyze`, `integrity_check`, +//! `set_pragma` all on `FileRepository`, which is wrong. +//! +//! WHY a port (not direct concrete `SqliteDatabaseAdmin` injection into the +//! use case): slice 2 (`vault.toml` writer) and slice 3 (restore) of +//! issue #168 will both want database-engine-level operations. A trait +//! gives them a clean home and lets tests inject a stub adapter. + +use std::path::Path; + +use crate::CoreError; + +/// Database-engine-level administration: backup (slice 1), restore (slice 3), +/// integrity-check (TBD). +/// +/// Adapters MUST be `Send + Sync` for `Arc` injection. +pub trait DatabaseAdmin: Send + Sync { + /// Produce a single-file consistent snapshot of the database at `target`. + /// + /// Returns `size_bytes` of the produced file on success. + /// + /// # Errors + /// + /// Returns `CoreError::BackupFailed { reason }` on any failure; + /// adapters map their underlying SQLite/IO errors to typed + /// [`crate::errors::BackupFailureReason`] variants. + fn backup(&self, target: &Path) -> Result; +} diff --git a/crates/core/src/ports/mod.rs b/crates/core/src/ports/mod.rs index 641e572..104f081 100644 --- a/crates/core/src/ports/mod.rs +++ b/crates/core/src/ports/mod.rs @@ -1,5 +1,6 @@ //! Trait ports — the hexagonal boundary between core and adapters. +pub mod database_admin; pub mod file_repo; pub mod hash; pub mod identity_cache; @@ -9,6 +10,7 @@ pub mod search_repo; pub mod tag_repo; pub mod volume_repo; +pub use database_admin::DatabaseAdmin; pub use file_repo::{BackfillFileRow, FileRepository}; pub use hash::HashService; pub use identity_cache::{CacheEntry, CacheKey, IdentityCacheRepository}; diff --git a/crates/core/tests/serialize_shape.rs b/crates/core/tests/serialize_shape.rs index 62afd47..8c6e3b3 100644 --- a/crates/core/tests/serialize_shape.rs +++ b/crates/core/tests/serialize_shape.rs @@ -490,3 +490,73 @@ fn invalidation_reason_collisions_changed_serializes_as_string() { assert_eq!(v["kind"], "IndexInvalidated"); assert_eq!(v["data"]["reason"], "CollisionsChanged"); } + +// ── Task 1 wire-shape pins (backup-slice-1) ──────────────────────────────── +// WHY: CoreError::BackupFailed + BackupFailureReason cross the IPC boundary. +// Pinning the wire shape here ensures the frontend's `parseCoreError` + +// `switch (err.kind)` / `switch (reason.kind)` blocks stay in sync with +// any future enum changes. Five variants = five tests, one each. + +#[test] +fn serialize_backup_failed_target_exists() { + use perima_core::errors::BackupFailureReason; + let err = perima_core::CoreError::BackupFailed { + reason: BackupFailureReason::TargetExists { + path: "/tmp/backup.sqlite".to_string(), + }, + }; + let json = serde_json::to_value(&err).expect("serialize"); + assert_eq!(json["kind"], "BackupFailed"); + assert_eq!(json["data"]["reason"]["kind"], "TargetExists"); + assert_eq!(json["data"]["reason"]["data"]["path"], "/tmp/backup.sqlite"); +} + +#[test] +fn serialize_backup_failed_target_unwritable() { + use perima_core::errors::BackupFailureReason; + let err = perima_core::CoreError::BackupFailed { + reason: BackupFailureReason::TargetUnwritable { + path: "/ro/backup.sqlite".to_string(), + message: "permission denied".to_string(), + }, + }; + let json = serde_json::to_value(&err).expect("serialize"); + assert_eq!(json["data"]["reason"]["kind"], "TargetUnwritable"); + assert_eq!(json["data"]["reason"]["data"]["path"], "/ro/backup.sqlite"); + assert_eq!( + json["data"]["reason"]["data"]["message"], + "permission denied" + ); +} + +#[test] +fn serialize_backup_failed_disk_full() { + use perima_core::errors::BackupFailureReason; + let err = perima_core::CoreError::BackupFailed { + reason: BackupFailureReason::DiskFull { + path: "/full/backup.sqlite".to_string(), + }, + }; + let json = serde_json::to_value(&err).expect("serialize"); + assert_eq!(json["data"]["reason"]["kind"], "DiskFull"); +} + +#[test] +fn serialize_backup_failed_already_in_progress() { + use perima_core::errors::BackupFailureReason; + let err = perima_core::CoreError::BackupFailed { + reason: BackupFailureReason::AlreadyInProgress, + }; + let json = serde_json::to_value(&err).expect("serialize"); + assert_eq!(json["data"]["reason"]["kind"], "AlreadyInProgress"); +} + +#[test] +fn serialize_backup_failed_internal() { + use perima_core::errors::BackupFailureReason; + let err = perima_core::CoreError::BackupFailed { + reason: BackupFailureReason::Internal("sqlite said no".to_string()), + }; + let json = serde_json::to_value(&err).expect("serialize"); + assert_eq!(json["data"]["reason"]["kind"], "Internal"); +} From 609367ae8be789db09398108a0c645af5a1ecb99 Mon Sep 17 00:00:00 2001 From: utof Date: Thu, 30 Apr 2026 22:10:10 +0400 Subject: [PATCH 02/11] feat(db): WriteCmd::Backup variant + writer handler running VACUUM INTO WHY routes through the writer (not a separate connection): the writer is the sole producer of writes in the perima Batch C connection model; serializing backup with other writes preserves the single-writer invariant and avoids competing reservations on the WAL. WHY no `force` field on BackupWriteCmd: pre-removal of existing target is the use case's responsibility (BackupDatabaseUseCase::execute owns the filesystem-state checks); the writer only runs SQL. WHY no `bus` parameter on backup::handle: backup emits no domain events in slice 1; the unused argument would be dead. Refs #168. --- crates/db/src/cmd.rs | 20 ++++++++++ crates/db/src/writer/backup.rs | 73 ++++++++++++++++++++++++++++++++++ crates/db/src/writer/mod.rs | 2 + 3 files changed, 95 insertions(+) create mode 100644 crates/db/src/writer/backup.rs diff --git a/crates/db/src/cmd.rs b/crates/db/src/cmd.rs index 713f86f..e6e4573 100644 --- a/crates/db/src/cmd.rs +++ b/crates/db/src/cmd.rs @@ -42,6 +42,12 @@ pub enum WriteCmd { Search(SearchWriteCmd), /// Identity-cache writes (populated Task 6 — cache port). Cache(CacheWriteCmd), + /// Produce a consistent single-file snapshot of the database at `target`. + /// Runs `VACUUM INTO ?1` against the writable connection. + /// + /// WHY no `force` field: existence-check + pre-removal happens in + /// `BackupDatabaseUseCase::execute`. The writer never sees `force`. + Backup(BackupWriteCmd), /// Cooperative shutdown signal — when the writer thread receives /// this it exits its loop. Sent by `SqliteWriterHandle::join` and /// the `Drop` impl. @@ -71,6 +77,7 @@ impl WriteCmd { Self::File(_) => "file", Self::Search(_) => "search", Self::Cache(c) => c.kind_str(), + Self::Backup(_) => "backup", Self::Shutdown => "shutdown", } } @@ -438,3 +445,16 @@ impl CacheWriteCmd { } } } + +/// Inner payload of [`WriteCmd::Backup`]. +/// +/// `target` is the absolute path the backup should land at; the +/// use-case has already pre-removed any existing file (when `--force`). +/// `reply` carries `size_bytes` on success or a typed `CoreError` on failure. +#[derive(Debug)] +pub struct BackupWriteCmd { + /// Absolute target path for the backup file. + pub target: std::path::PathBuf, + /// Reply channel; writer sends `Ok(size_bytes)` on success. + pub reply: flume::Sender>, +} diff --git a/crates/db/src/writer/backup.rs b/crates/db/src/writer/backup.rs new file mode 100644 index 0000000..cb8c363 --- /dev/null +++ b/crates/db/src/writer/backup.rs @@ -0,0 +1,73 @@ +//! Writer-side handler for `WriteCmd::Backup`. +//! +//! WHY runs in the writer thread (not in a read pool): VACUUM INTO +//! takes a brief reserved-write lock on the source DB. The writer is +//! the only producer of writes in the perima architecture (Batch C); +//! routing backup through it preserves the single-writer invariant +//! and serializes naturally with other writes. +//! +//! WHY no `bus` parameter (asymmetric vs other writer handlers): +//! backup emits no domain events — slice 1 has no `AppEvent::BackupProgress` +//! or `BackupCompleted`. Adding an unused `bus` parameter would be dead. + +use std::path::Path; + +use perima_core::{CoreError, errors::BackupFailureReason}; +use rusqlite::Connection; + +use crate::cmd::BackupWriteCmd; + +/// Run `VACUUM INTO ?1` against the writable connection and reply with +/// the produced file's size in bytes. +/// +/// WHY size-after-VACUUM (not pre-allocate then size): VACUUM INTO is +/// atomic from `SQLite`'s perspective — a successful return guarantees +/// the target file exists and is complete. We then `fs::metadata` to +/// report the size to the user (slice 1 spec §IPC surface). +// WHY allow needless_pass_by_value: `cmd` is passed by value because +// `handle` moves `cmd.reply` (a `flume::Sender`) to send the result. +// Taking `&BackupWriteCmd` would prevent moving the sender out. +#[allow(clippy::needless_pass_by_value)] +pub(super) fn handle(conn: &Connection, cmd: BackupWriteCmd) { + let result = run(conn, &cmd.target); + let _ = cmd.reply.send(result); +} + +fn run(conn: &Connection, target: &Path) -> Result { + let target_str = target.to_string_lossy(); + conn.execute("VACUUM INTO ?1", rusqlite::params![target_str.as_ref()]) + .map_err(|e| { + // SQLite reports SQLITE_FULL as ErrorCode::DiskFull; + // SQLITE_CANTOPEN often means the target dir is unwritable. + if let rusqlite::Error::SqliteFailure(ffi_err, _) = &e { + match ffi_err.code { + rusqlite::ErrorCode::DiskFull => { + return CoreError::BackupFailed { + reason: BackupFailureReason::DiskFull { + path: target.display().to_string(), + }, + }; + } + rusqlite::ErrorCode::CannotOpen => { + return CoreError::BackupFailed { + reason: BackupFailureReason::TargetUnwritable { + path: target.display().to_string(), + message: e.to_string(), + }, + }; + } + _ => {} + } + } + CoreError::BackupFailed { + reason: BackupFailureReason::Internal(e.to_string()), + } + })?; + + let size = std::fs::metadata(target) + .map_err(|e| CoreError::BackupFailed { + reason: BackupFailureReason::Internal(format!("metadata after VACUUM INTO: {e}")), + })? + .len(); + Ok(size) +} diff --git a/crates/db/src/writer/mod.rs b/crates/db/src/writer/mod.rs index 427e89f..df7e52c 100644 --- a/crates/db/src/writer/mod.rs +++ b/crates/db/src/writer/mod.rs @@ -52,6 +52,7 @@ use crate::cmd::WriteCmd; use crate::connection::open_and_migrate; use crate::schema::install_fts_triggers; +mod backup; mod cache; mod file; mod metadata; @@ -272,6 +273,7 @@ fn dispatch(conn: &mut Connection, cmd: WriteCmd, bus: &Arc) { WriteCmd::File(c) => handle_file(conn, c, bus), WriteCmd::Search(c) => search::handle(conn, c, bus), WriteCmd::Cache(c) => cache::handle(conn, c, bus), + WriteCmd::Backup(cmd) => backup::handle(conn, cmd), // WHY unreachable: Shutdown is short-circuited in // `run_writer_loop` BEFORE this dispatch is invoked. Reaching // here means the loop ordering changed without updating From 7ae94250c79413abe6bc65e3c604cf05bb1a0b58 Mon Sep 17 00:00:00 2001 From: utof Date: Thu, 30 Apr 2026 22:17:57 +0400 Subject: [PATCH 03/11] =?UTF-8?q?fix(db):=20backup=20handler=20=E2=80=94?= =?UTF-8?q?=20canonical=20reply-send=20pattern=20+=20UTF-8=20path=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-quality review on the Task 2 commit (609367a) caught two divergences from existing writer-handler precedent: 1. `let _ = reply.send(...)` swallows the closed-channel signal AND breaks the canonical `tracing::debug!("...reply channel closed...")` log line that every other handler emits (cache, volume, tag, metadata, file, search). Restore the canonical shape so a future grep for "reply channel closed" includes backup. 2. `to_string_lossy()` silently maps invalid UTF-8 to U+FFFD and would send the mangled string to SQLite, surfacing as a CannotOpen error against a path that does not exist on disk — confusing the user. Fail fast with TargetUnwritable carrying "target path is not valid UTF-8". Mirrors crates/db/src/writer/volume.rs:242-251 + its regression test in crates/db/src/volume_repo.rs:348-369. Refs #168. --- crates/db/src/writer/backup.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/db/src/writer/backup.rs b/crates/db/src/writer/backup.rs index cb8c363..0466af9 100644 --- a/crates/db/src/writer/backup.rs +++ b/crates/db/src/writer/backup.rs @@ -30,12 +30,25 @@ use crate::cmd::BackupWriteCmd; #[allow(clippy::needless_pass_by_value)] pub(super) fn handle(conn: &Connection, cmd: BackupWriteCmd) { let result = run(conn, &cmd.target); - let _ = cmd.reply.send(result); + if cmd.reply.send(result).is_err() { + tracing::debug!("backup reply channel closed before send"); + } } fn run(conn: &Connection, target: &Path) -> Result { - let target_str = target.to_string_lossy(); - conn.execute("VACUUM INTO ?1", rusqlite::params![target_str.as_ref()]) + // WHY to_str (not to_string_lossy): a non-UTF8 target would otherwise + // be silently mangled to U+FFFD, sent to SQLite, which would fail with + // CannotOpen against a phantom path — the user sees an error citing a + // path that doesn't exist on disk. Fail fast with TargetUnwritable so + // the user sees the actual path and can fix it. Mirrors the precedent + // at crates/db/src/writer/volume.rs:242-251. + let target_str = target.to_str().ok_or_else(|| CoreError::BackupFailed { + reason: BackupFailureReason::TargetUnwritable { + path: target.display().to_string(), + message: "target path is not valid UTF-8".to_string(), + }, + })?; + conn.execute("VACUUM INTO ?1", rusqlite::params![target_str]) .map_err(|e| { // SQLite reports SQLITE_FULL as ErrorCode::DiskFull; // SQLITE_CANTOPEN often means the target dir is unwritable. From a977d28b6844f2aab19f1ce2a176df7e172b2078 Mon Sep 17 00:00:00 2001 From: utof Date: Thu, 30 Apr 2026 22:27:56 +0400 Subject: [PATCH 04/11] =?UTF-8?q?feat(db):=20SqliteDatabaseAdmin=20adapter?= =?UTF-8?q?=20=E2=80=94=20writer-dispatched=20backup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin adapter implementing perima_core::ports::DatabaseAdmin by sending WriteCmd::Backup through the existing writer-actor command channel and awaiting the reply via flume::bounded(1) (one-shot reply pattern, per Batch C). No new connection, no new lock. WHY map writer-channel errors to BackupFailed { reason: Internal(...) }: keeps the typed-error contract intact at the IPC seam — frontend pattern-matches on err.kind === "BackupFailed" for all failure paths. Refs #168. --- crates/db/src/database_admin.rs | 53 +++++++++++++++++++++++++++++++++ crates/db/src/lib.rs | 2 ++ 2 files changed, 55 insertions(+) create mode 100644 crates/db/src/database_admin.rs diff --git a/crates/db/src/database_admin.rs b/crates/db/src/database_admin.rs new file mode 100644 index 0000000..c20c582 --- /dev/null +++ b/crates/db/src/database_admin.rs @@ -0,0 +1,53 @@ +//! `DatabaseAdmin` adapter — sends `WriteCmd::Backup` to the writer actor +//! and awaits the reply via `flume::bounded(1)`. + +use std::path::Path; + +use flume::Sender; +use perima_core::{CoreError, errors::BackupFailureReason, ports::DatabaseAdmin}; + +use crate::cmd::{BackupWriteCmd, WriteCmd}; + +/// SQLite-backed `DatabaseAdmin` adapter. +/// +/// Holds a `Sender` to the single writer actor; backup +/// requests serialize naturally with other writes. +#[derive(Clone, Debug)] +pub struct SqliteDatabaseAdmin { + writer: Sender, +} + +impl SqliteDatabaseAdmin { + /// Construct from the writer command sender (typically obtained via + /// `SqliteWriter::start(...)?.sender()`). + #[must_use] + pub const fn new(writer: Sender) -> Self { + Self { writer } + } +} + +impl DatabaseAdmin for SqliteDatabaseAdmin { + fn backup(&self, target: &Path) -> Result { + // WHY flume::bounded(1) reply (not unbounded): one-shot reply + // pattern across the codebase (per Batch C). bounded(1) means + // the writer can't pile up replies if the use case dies. + let (reply_tx, reply_rx) = flume::bounded(1); + + // WHY map send/recv errors to BackupFailed { reason: Internal(...) } + // rather than bare CoreError::Internal: keeps the typed-error + // contract intact; frontend pattern-matches on err.kind === + // "BackupFailed" for ALL backup failure paths. + self.writer + .send(WriteCmd::Backup(BackupWriteCmd { + target: target.to_path_buf(), + reply: reply_tx, + })) + .map_err(|e| CoreError::BackupFailed { + reason: BackupFailureReason::Internal(format!("writer send: {e}")), + })?; + + reply_rx.recv().map_err(|e| CoreError::BackupFailed { + reason: BackupFailureReason::Internal(format!("writer reply: {e}")), + })? + } +} diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 3654ec1..d389ce0 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -4,6 +4,7 @@ pub mod cmd; pub mod connection; +pub mod database_admin; pub mod errors; pub mod file_repo; pub mod identity_cache_repo; @@ -18,6 +19,7 @@ pub mod writer; pub use cmd::WriteCmd; pub use connection::open_and_migrate; +pub use database_admin::SqliteDatabaseAdmin; pub use errors::Error; pub use file_repo::SqliteFileRepository; pub use identity_cache_repo::SqliteIdentityCacheRepository; From 5d1a6e6492d74894f6efe82b1aee0dd2fcc283e8 Mon Sep 17 00:00:00 2001 From: utof Date: Thu, 30 Apr 2026 22:46:54 +0400 Subject: [PATCH 05/11] feat(app): BackupDatabaseUseCase + resolve_target + AtomicBool guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Online single-file backup orchestration via the writer-actor + DatabaseAdmin port. Default target stacks indefinitely via /backups/perima-.sqlite (no overwrite by default). --force pre-removes existing target at the use-case level (writer never sees force). WHY AtomicBool + BackupGuard Drop-impl: prevents concurrent backup attempts (CLI + UI race, double-click, multi-window) from racing on the same writer. Panic-safe AND tokio-cancel-safe via stack-RAII. Tests: - 3 unit tests for resolve_target (UTC ISO, no colons, explicit override) - backup produces a valid SQLite (sqlite3 can open + read row count) - writer resumes normal writes after backup (regression class) - backup serializes correctly behind 100 pending upserts (FIFO) - default path lands under /backups/ - two concurrent backups via SlowAdmin → one Ok + one AlreadyInProgress (deterministic; not VACUUM-INTO-timing-dependent) - unwritable target → typed TargetUnwritable error (Linux/macOS; ignored on Windows runner) Also: new crates/app/tests/common/mod.rs with shared helpers (noop_bus, fresh_env, insert_n_file_rows, count_files, SlowAdmin). Load-bearing allow(unreachable_pub)+allow(dead_code) headers per Batch F+G. Refs #168. --- Cargo.lock | 1 + crates/app/Cargo.toml | 5 + crates/app/src/backup.rs | 234 ++++++++++++++++++ crates/app/src/lib.rs | 2 + ..._concurrent_returns_already_in_progress.rs | 68 +++++ .../app/tests/backup_creates_valid_sqlite.rs | 46 ++++ .../backup_default_path_under_data_dir.rs | 52 ++++ .../backup_serializes_with_pending_writes.rs | 70 ++++++ crates/app/tests/backup_target_unwritable.rs | 54 ++++ .../backup_writer_resumes_normal_writes.rs | 65 +++++ crates/app/tests/common/mod.rs | 150 +++++++++++ 11 files changed, 747 insertions(+) create mode 100644 crates/app/src/backup.rs create mode 100644 crates/app/tests/backup_concurrent_returns_already_in_progress.rs create mode 100644 crates/app/tests/backup_creates_valid_sqlite.rs create mode 100644 crates/app/tests/backup_default_path_under_data_dir.rs create mode 100644 crates/app/tests/backup_serializes_with_pending_writes.rs create mode 100644 crates/app/tests/backup_target_unwritable.rs create mode 100644 crates/app/tests/backup_writer_resumes_normal_writes.rs create mode 100644 crates/app/tests/common/mod.rs diff --git a/Cargo.lock b/Cargo.lock index effe327..0b8da18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3521,6 +3521,7 @@ version = "0.6.4" dependencies = [ "async-broadcast", "async-trait", + "chrono", "directories", "futures", "insta", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 8e6f923..b4e555b 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -39,6 +39,11 @@ serde.workspace = true rayon.workspace = true # WHY uuid: VolumeId fallback in dry-run path uses `Uuid::nil()`. uuid.workspace = true +# WHY chrono: BackupDatabaseUseCase formats default-target ISO 8601 UTC +# timestamps in the backup filename (`perima-.sqlite`). chrono is +# already a transitive workspace dep; pulling it in as a direct dep +# documents the intent at the use-case layer. +chrono.workspace = true # WHY optional specta: see [features] above. Not a hard dep — normal # `cargo build` of `perima-app` (CLI, tests) does not pull in specta. specta = { workspace = true, optional = true } diff --git a/crates/app/src/backup.rs b/crates/app/src/backup.rs new file mode 100644 index 0000000..873797e --- /dev/null +++ b/crates/app/src/backup.rs @@ -0,0 +1,234 @@ +//! Online single-file `SQLite` backup orchestration. +//! +//! [`BackupDatabaseUseCase`] resolves the target path, enforces +//! single-call concurrency via an `AtomicBool` + RAII guard, +//! pre-removes when `--force`, and dispatches the actual copy via +//! [`perima_core::ports::DatabaseAdmin`]. +//! +//! # Why a use case (not direct adapter call from shells) +//! +//! Path resolution + force semantics + concurrency-gating are +//! application-layer concerns; the writer-actor adapter only knows +//! "VACUUM INTO this path". Keeping the policy here lets CLI + Tauri +//! IPC + future scheduler share one implementation. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use perima_core::{CoreError, errors::BackupFailureReason, ports::DatabaseAdmin}; +use serde::Serialize; + +/// Successful output of [`BackupDatabaseUseCase::execute`]. +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +pub struct BackupOutput { + /// Absolute path to the freshly written backup file. + pub absolute_path: PathBuf, + /// Size in bytes of the freshly written backup file. + pub size_bytes: u64, +} + +/// Input to [`BackupDatabaseUseCase::execute`]. +/// +/// WHY no specta derive: this type is internal to the use-case; the IPC +/// handler reconstructs from `(target, force)` IPC args, so we don't +/// need to expose `BackupCommand` to the TS bindings. +#[derive(Debug, Clone)] +pub struct BackupCommand { + /// Explicit destination path, if user passed `--to `. `None` + /// triggers default-path resolution under `/backups/`. + pub target: Option, + /// If `true`, an existing target file is removed before the backup + /// runs. If `false`, an existing target returns + /// [`BackupFailureReason::TargetExists`]. + pub force: bool, +} + +/// Backup orchestration: resolves target, enforces in-flight guard, +/// pre-removes when `--force`, and dispatches via [`DatabaseAdmin`]. +pub struct BackupDatabaseUseCase { + admin: Arc, + data_dir: PathBuf, + in_flight: Arc, +} + +// WHY manual Debug (vs. derive): `Arc` is not Debug — +// the trait does not require it. Matches the pattern used for every +// other UseCase struct in this crate (see ScanUseCase, MetadataUseCase). +impl std::fmt::Debug for BackupDatabaseUseCase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BackupDatabaseUseCase") + .finish_non_exhaustive() + } +} + +impl BackupDatabaseUseCase { + /// Construct a use case from the database-admin adapter and the + /// resolved on-disk data directory (used to compute the default + /// target path under `/backups/`). + #[must_use] + pub fn new(admin: Arc, data_dir: PathBuf) -> Self { + Self { + admin, + data_dir, + in_flight: Arc::new(AtomicBool::new(false)), + } + } + + /// Run a single backup. + /// + /// # Errors + /// + /// Returns [`CoreError::BackupFailed`] with one of: + /// - [`BackupFailureReason::AlreadyInProgress`] — another backup is + /// running on this instance. + /// - [`BackupFailureReason::TargetExists`] — target already exists + /// and `force` was not passed. + /// - [`BackupFailureReason::TargetUnwritable`] — parent directory + /// could not be created or pre-existing file could not be removed. + /// - Anything propagated from the underlying [`DatabaseAdmin::backup`]. + // WHY allow(unused_async): the body holds no `.await`. The `async` + // keyword on `execute` is the canonical UseCase shape (matches + // ScanUseCase, MetadataUseCase, …); preserving it leaves the door + // open for future awaits without churn at every callsite. + #[allow(clippy::unused_async)] + pub async fn execute(&self, cmd: BackupCommand) -> Result { + // Acquire in-flight slot; release in `BackupGuard::drop`. + let _guard = BackupGuard::acquire(&self.in_flight)?; + + let now = chrono::Utc::now(); + let target = resolve_target(cmd.target.as_deref(), &self.data_dir, now); + + // Create parent directory if missing. + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|e| CoreError::BackupFailed { + reason: BackupFailureReason::TargetUnwritable { + path: target.display().to_string(), + message: format!("create_dir_all parent: {e}"), + }, + })?; + } + + // Existence check + force semantics. + if target.exists() { + if cmd.force { + std::fs::remove_file(&target).map_err(|e| CoreError::BackupFailed { + reason: BackupFailureReason::TargetUnwritable { + path: target.display().to_string(), + message: format!("remove pre-existing: {e}"), + }, + })?; + } else { + return Err(CoreError::BackupFailed { + reason: BackupFailureReason::TargetExists { + path: target.display().to_string(), + }, + }); + } + } + + let size_bytes = self.admin.backup(&target)?; + + Ok(BackupOutput { + absolute_path: target, + size_bytes, + }) + } +} + +/// RAII guard for the in-flight slot. +/// +/// WHY a guard (not bare `compare_exchange` + manual release): a panic +/// inside `execute` would otherwise strand the flag set, locking out +/// all future backups. `Drop` is panic-safe. +/// +/// WHY this also handles `tokio::spawn` cancellation: `BackupGuard` is +/// a stack value inside `execute()`. If the future is dropped (cancelled) +/// between `acquire()` and natural completion, Rust's drop ordering runs +/// `BackupGuard::drop` as the future's frame is unwound — the slot is +/// released. A future "optimisation" that hoists the guard out of the +/// stack frame would break this property; do NOT do that. +struct BackupGuard<'a> { + flag: &'a AtomicBool, +} + +impl<'a> BackupGuard<'a> { + fn acquire(flag: &'a AtomicBool) -> Result { + flag.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .map_err(|_| CoreError::BackupFailed { + reason: BackupFailureReason::AlreadyInProgress, + })?; + Ok(Self { flag }) + } +} + +impl Drop for BackupGuard<'_> { + fn drop(&mut self) { + self.flag.store(false, Ordering::Release); + } +} + +/// Resolve the backup target path. +/// +/// `Some(p)` → `p` verbatim. `None` → +/// `/backups/perima-.sqlite`. +/// +/// WHY UTC + hyphens (not local time + colons): backups sort +/// lexicographically across machines and DST transitions; Windows + +/// classic-macOS reject `:` in filenames. The `Z` suffix marks UTC +/// explicitly. +#[must_use] +pub fn resolve_target( + cmd_target: Option<&Path>, + data_dir: &Path, + now: chrono::DateTime, +) -> PathBuf { + if let Some(p) = cmd_target { + return p.to_path_buf(); + } + let stamp = now.format("%Y-%m-%dT%H-%M-%SZ").to_string(); + data_dir + .join("backups") + .join(format!("perima-{stamp}.sqlite")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_target_uses_explicit_when_provided() { + let p = PathBuf::from("/explicit/path.sqlite"); + let now = chrono::DateTime::::default(); + assert_eq!(resolve_target(Some(&p), Path::new("/data"), now), p); + } + + #[test] + fn resolve_target_default_uses_iso_filename() { + let now = chrono::DateTime::::from_timestamp(1_714_492_200, 0) + .expect("valid timestamp"); + let got = resolve_target(None, Path::new("/data"), now); + let s = got.to_string_lossy(); + assert!( + s.contains("perima-"), + "filename should start with perima-: {s}" + ); + assert!(s.ends_with(".sqlite"), "extension should be .sqlite: {s}"); + assert!( + s.contains("Z."), + "UTC marker should appear before extension: {s}" + ); + } + + #[test] + fn resolve_target_replaces_colons_for_windows() { + let now = chrono::Utc::now(); + let got = resolve_target(None, Path::new("/data"), now); + assert!( + !got.to_string_lossy().contains(':'), + "filename must not contain ':' (Windows hostile): {}", + got.display() + ); + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index dd262b1..a1f5f24 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -26,6 +26,7 @@ #![forbid(unsafe_code)] pub mod backfill; +pub mod backup; pub mod bus; pub mod config; pub mod container; @@ -39,6 +40,7 @@ pub mod telemetry; pub mod volume; pub use backfill::{BackfillRate, BackfillReport, BackfillRow, QuickHashBackfillWorker}; +pub use backup::{BackupCommand, BackupDatabaseUseCase, BackupOutput}; pub use bus::Bus; pub use container::{AppContainer, AppDeps}; pub use dedup::{ComputeFullHashUseCase, DedupUseCase}; diff --git a/crates/app/tests/backup_concurrent_returns_already_in_progress.rs b/crates/app/tests/backup_concurrent_returns_already_in_progress.rs new file mode 100644 index 0000000..1ebb13c --- /dev/null +++ b/crates/app/tests/backup_concurrent_returns_already_in_progress.rs @@ -0,0 +1,68 @@ +//! Verify the in-flight `AtomicBool` guard refuses concurrent backups. +//! +//! Spec slice-1 §4.6 (concurrency). Uses a `SlowAdmin` test-double +//! that sleeps for 200ms before producing a fake backup file — gives +//! us a deterministic 10ms-stagger window to confirm the second +//! `execute` returns `AlreadyInProgress` instead of racing on the +//! VACUUM INTO timing of a real adapter. + +#![allow(clippy::unwrap_used)] // WHY: test code; unwrap panics signal bugs. + +use std::sync::Arc; + +use perima_app::{BackupCommand, BackupDatabaseUseCase}; +use perima_core::{CoreError, errors::BackupFailureReason, ports::DatabaseAdmin}; + +mod common; +use common::SlowAdmin; + +#[tokio::test(flavor = "multi_thread")] +async fn two_concurrent_backups_one_succeeds_one_returns_already_in_progress() { + let tmp = tempfile::tempdir().expect("tempdir"); + let admin: Arc = Arc::new(SlowAdmin { sleep_ms: 200 }); + let use_case = Arc::new(BackupDatabaseUseCase::new(admin, tmp.path().to_path_buf())); + + let uc1 = Arc::clone(&use_case); + let uc2 = Arc::clone(&use_case); + let p1 = tmp.path().join("a.sqlite"); + let p2 = tmp.path().join("b.sqlite"); + + let h1 = tokio::spawn(async move { + uc1.execute(BackupCommand { + target: Some(p1), + force: false, + }) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let h2 = tokio::spawn(async move { + uc2.execute(BackupCommand { + target: Some(p2), + force: false, + }) + .await + }); + + let r1 = h1.await.expect("join1"); + let r2 = h2.await.expect("join2"); + + assert!(r1.is_ok(), "first backup should succeed: {r1:?}"); + assert!( + matches!( + r2, + Err(CoreError::BackupFailed { + reason: BackupFailureReason::AlreadyInProgress + }) + ), + "second backup should return AlreadyInProgress, got {r2:?}" + ); + + let p3 = tmp.path().join("c.sqlite"); + use_case + .execute(BackupCommand { + target: Some(p3), + force: false, + }) + .await + .expect("post-contention backup must succeed"); +} diff --git a/crates/app/tests/backup_creates_valid_sqlite.rs b/crates/app/tests/backup_creates_valid_sqlite.rs new file mode 100644 index 0000000..699fdd5 --- /dev/null +++ b/crates/app/tests/backup_creates_valid_sqlite.rs @@ -0,0 +1,46 @@ +//! Verify `BackupDatabaseUseCase::execute` produces a `SQLite` file that +//! can be opened independently and contains the same row count as the +//! source. +//! +//! Spec slice-1 §4.6. This is the canonical happy-path: real +//! `SqliteDatabaseAdmin` adapter + writer, one seeded row, one backup, +//! one read-only-open count assertion. + +#![allow(clippy::unwrap_used)] // WHY: test code; unwrap panics signal bugs. + +use std::sync::Arc; + +use perima_app::{BackupCommand, BackupDatabaseUseCase}; +use perima_core::ports::DatabaseAdmin; +use perima_db::SqliteDatabaseAdmin; + +mod common; +use common::{count_files, fresh_env, insert_one_file_row}; + +#[tokio::test] +async fn backup_produces_valid_sqlite_with_correct_row_count() { + let env = fresh_env(); + insert_one_file_row(&env); + + let admin: Arc = Arc::new(SqliteDatabaseAdmin::new(env.writer.sender())); + let use_case = BackupDatabaseUseCase::new(admin, env.tmp.path().to_path_buf()); + + let target = env.tmp.path().join("backup.sqlite"); + let out = use_case + .execute(BackupCommand { + target: Some(target.clone()), + force: false, + }) + .await + .expect("backup should succeed"); + + assert_eq!(out.absolute_path, target); + assert!(out.size_bytes > 0, "backup file should be non-empty"); + assert!(target.exists(), "backup file should exist on disk"); + + let backup_count = count_files(&target); + assert_eq!( + backup_count, 1, + "backup snapshot must contain the one seeded row" + ); +} diff --git a/crates/app/tests/backup_default_path_under_data_dir.rs b/crates/app/tests/backup_default_path_under_data_dir.rs new file mode 100644 index 0000000..1b27806 --- /dev/null +++ b/crates/app/tests/backup_default_path_under_data_dir.rs @@ -0,0 +1,52 @@ +//! Verify default-target resolution lands the file under +//! `/backups/perima-.sqlite`. +//! +//! Spec slice-1 §4.6 (default target). Complements the unit-level +//! `resolve_target_default_uses_iso_filename` test by exercising the +//! actual filesystem write through the use case. + +#![allow(clippy::unwrap_used)] // WHY: test code; unwrap panics signal bugs. + +use std::sync::Arc; + +use perima_app::{BackupCommand, BackupDatabaseUseCase}; +use perima_core::ports::DatabaseAdmin; +use perima_db::SqliteDatabaseAdmin; + +mod common; +use common::{fresh_env, insert_one_file_row}; + +#[tokio::test] +async fn default_path_lands_under_data_dir_backups_subdir() { + let env = fresh_env(); + insert_one_file_row(&env); + + let admin: Arc = Arc::new(SqliteDatabaseAdmin::new(env.writer.sender())); + let data_dir = env.tmp.path().to_path_buf(); + let use_case = BackupDatabaseUseCase::new(admin, data_dir.clone()); + + let out = use_case + .execute(BackupCommand { + target: None, + force: false, + }) + .await + .expect("default-path backup should succeed"); + + let backups_dir = data_dir.join("backups"); + assert!( + out.absolute_path.starts_with(&backups_dir), + "backup must live under /backups/, got {}", + out.absolute_path.display() + ); + let fname = out + .absolute_path + .file_name() + .and_then(|s| s.to_str()) + .expect("utf-8 filename"); + assert!( + fname.starts_with("perima-") && fname.ends_with(".sqlite"), + "filename must be perima-.sqlite, got {fname}" + ); + assert!(out.absolute_path.exists(), "backup file should exist"); +} diff --git a/crates/app/tests/backup_serializes_with_pending_writes.rs b/crates/app/tests/backup_serializes_with_pending_writes.rs new file mode 100644 index 0000000..3ed6065 --- /dev/null +++ b/crates/app/tests/backup_serializes_with_pending_writes.rs @@ -0,0 +1,70 @@ +//! Verify backup snapshot is consistent with the writer's serialised +//! command order: rows enqueued BEFORE the backup are visible in the +//! snapshot; rows enqueued AFTER are not. +//! +//! Spec slice-1 §4.6: the writer is a single-actor FIFO; backup is just +//! another `WriteCmd`. Sending 100 upserts → backup → 1 more upsert +//! must yield a snapshot with exactly 100 rows and a live db with 101. + +#![allow(clippy::unwrap_used)] // WHY: test code; unwrap panics signal bugs. + +use std::sync::Arc; + +use perima_app::{BackupCommand, BackupDatabaseUseCase}; +use perima_core::{FileRepository, ports::DatabaseAdmin}; +use perima_db::SqliteDatabaseAdmin; + +mod common; +use common::{count_files, fresh_env, insert_n_file_rows}; + +#[tokio::test] +async fn backup_snapshot_contains_pre_backup_rows_only() { + let env = fresh_env(); + insert_n_file_rows(&env, 100); + + let admin: Arc = Arc::new(SqliteDatabaseAdmin::new(env.writer.sender())); + let use_case = BackupDatabaseUseCase::new(admin, env.tmp.path().to_path_buf()); + + let target = env.tmp.path().join("snapshot.sqlite"); + use_case + .execute(BackupCommand { + target: Some(target.clone()), + force: false, + }) + .await + .expect("backup should succeed"); + + // One more row enqueued AFTER the backup completes. + // (`execute` returns once the writer's reply lands → backup's VACUUM + // INTO has finished → next upsert must follow it in writer FIFO.) + let post_repo = perima_db::SqliteFileRepository::new(env.writer.sender(), env.reads.clone()); + let lo = 100u32; + let mut bytes = [0u8; 32]; + bytes[0..4].copy_from_slice(&lo.to_le_bytes()); + let hash = perima_core::BlakeHash::from_bytes(bytes); + post_repo + .upsert_file_with_quick_hash( + &perima_core::HashedFile { + discovered: perima_core::DiscoveredFile { + absolute_path: env.tmp.path().join("post-100.bin"), + relative_path: perima_core::MediaPath::new("post-100.bin"), + size: perima_core::FileSize(64), + }, + hash, + }, + perima_core::DeviceId::default(), + None, + ) + .expect("post-backup upsert"); + + assert_eq!( + count_files(&target), + 100, + "snapshot must contain only pre-backup rows" + ); + assert_eq!( + count_files(&env.db_path()), + 101, + "live db must contain pre-backup + post-backup rows" + ); +} diff --git a/crates/app/tests/backup_target_unwritable.rs b/crates/app/tests/backup_target_unwritable.rs new file mode 100644 index 0000000..8a68bbe --- /dev/null +++ b/crates/app/tests/backup_target_unwritable.rs @@ -0,0 +1,54 @@ +//! Verify a target whose parent we can neither create nor write to +//! returns `BackupFailureReason::TargetUnwritable`. +//! +//! Spec slice-1 §4.6 (typed error UX). On Linux we abuse `/proc/1/foo/` +//! (procfs is read-only); on macOS we abuse `/System/Volumes/Data/.fs.write/` +//! (SIP-protected). Windows runners have no equivalent portable +//! always-unwritable path, so we ignore the test there. + +#![allow(clippy::unwrap_used)] // WHY: test code; unwrap panics signal bugs. + +use std::sync::Arc; + +use perima_app::{BackupCommand, BackupDatabaseUseCase}; +use perima_core::{CoreError, errors::BackupFailureReason, ports::DatabaseAdmin}; +use perima_db::SqliteDatabaseAdmin; + +mod common; +use common::{fresh_env, insert_one_file_row}; + +#[cfg_attr( + target_os = "windows", + ignore = "no portable unwritable path on Windows runners" +)] +#[tokio::test] +async fn target_with_unwritable_parent_returns_target_unwritable() { + let env = fresh_env(); + insert_one_file_row(&env); + + let admin: Arc = Arc::new(SqliteDatabaseAdmin::new(env.writer.sender())); + let use_case = BackupDatabaseUseCase::new(admin, env.tmp.path().to_path_buf()); + + let unwritable = if cfg!(target_os = "linux") { + std::path::PathBuf::from("/proc/1/foo/backup.sqlite") + } else { + std::path::PathBuf::from("/System/Volumes/Data/.fs.write/test/backup.sqlite") + }; + + let res = use_case + .execute(BackupCommand { + target: Some(unwritable), + force: false, + }) + .await; + + assert!( + matches!( + res, + Err(CoreError::BackupFailed { + reason: BackupFailureReason::TargetUnwritable { .. } + }) + ), + "expected TargetUnwritable, got {res:?}" + ); +} diff --git a/crates/app/tests/backup_writer_resumes_normal_writes.rs b/crates/app/tests/backup_writer_resumes_normal_writes.rs new file mode 100644 index 0000000..45e4916 --- /dev/null +++ b/crates/app/tests/backup_writer_resumes_normal_writes.rs @@ -0,0 +1,65 @@ +//! Verify the writer accepts new writes after a backup completes. +//! +//! Spec slice-1 §4.6 regression class: an earlier design considered +//! pausing the writer for the duration of the VACUUM INTO. That would +//! deadlock further upserts. This test asserts the writer keeps running. + +#![allow(clippy::unwrap_used)] // WHY: test code; unwrap panics signal bugs. + +use std::sync::Arc; + +use perima_app::{BackupCommand, BackupDatabaseUseCase}; +use perima_core::{ + BlakeHash, DeviceId, DiscoveredFile, FileRepository, FileSize, HashedFile, MediaPath, + ports::DatabaseAdmin, +}; +use perima_db::{SqliteDatabaseAdmin, SqliteFileRepository}; + +mod common; +use common::{count_files, fresh_env, insert_n_file_rows}; + +#[tokio::test] +async fn writer_accepts_writes_after_backup_completes() { + let env = fresh_env(); + insert_n_file_rows(&env, 1); + + let admin: Arc = Arc::new(SqliteDatabaseAdmin::new(env.writer.sender())); + let use_case = BackupDatabaseUseCase::new(admin, env.tmp.path().to_path_buf()); + + let target = env.tmp.path().join("backup.sqlite"); + use_case + .execute(BackupCommand { + target: Some(target), + force: false, + }) + .await + .expect("first backup should succeed"); + + // Insert ANOTHER row after the backup, with a distinct hash so it + // doesn't collide with the seeded i=0 row. If the writer were + // stuck post-backup, this upsert would hang. + let post_repo = SqliteFileRepository::new(env.writer.sender(), env.reads.clone()); + let mut bytes = [0u8; 32]; + bytes[0..4].copy_from_slice(&7u32.to_le_bytes()); + let hash = BlakeHash::from_bytes(bytes); + post_repo + .upsert_file_with_quick_hash( + &HashedFile { + discovered: DiscoveredFile { + absolute_path: env.tmp.path().join("post.bin"), + relative_path: MediaPath::new("post.bin"), + size: FileSize(64), + }, + hash, + }, + DeviceId::default(), + None, + ) + .expect("post-backup upsert"); + + let live_count = count_files(&env.db_path()); + assert_eq!( + live_count, 2, + "live db should contain both seeded rows after backup" + ); +} diff --git a/crates/app/tests/common/mod.rs b/crates/app/tests/common/mod.rs new file mode 100644 index 0000000..f9f90fa --- /dev/null +++ b/crates/app/tests/common/mod.rs @@ -0,0 +1,150 @@ +//! Shared test helpers for `crates/app/tests/*.rs`. +//! +//! WHY load-bearing `#![allow(unreachable_pub)]` + `#![allow(dead_code)]` +//! headers: every integration-test binary compiles `common/mod.rs` but +//! only uses a subset of the helpers. Without these allows, each binary +//! sees the unused helpers as warnings → CI's `-D warnings` fails. +//! Per CLAUDE.md "Test architecture (Batch F + G)" + rust-lang/rust#46379. +#![allow(unreachable_pub)] +#![allow(dead_code)] +#![allow(clippy::unwrap_used)] // WHY: test code; unwrap panics signal bugs. + +use std::path::Path; +use std::sync::Arc; + +use perima_core::{ + AppEvent, BlakeHash, CoreError, DeviceId, DiscoveredFile, EventBus, FileRepository, FileSize, + HashedFile, MediaPath, ports::DatabaseAdmin, +}; +use perima_db::{ReadPool, SqliteFileRepository, SqliteWriter, SqliteWriterHandle}; +use rusqlite::OpenFlags; +use tempfile::TempDir; + +/// `EventBus` stub that drops all emissions. +pub struct NoopBus; + +impl EventBus for NoopBus { + fn emit(&self, _event: &AppEvent) -> Result<(), CoreError> { + Ok(()) + } +} + +#[must_use] +pub fn noop_bus() -> Arc { + Arc::new(NoopBus) +} + +/// Bundle of (tempdir, writer-handle, read-pool, bus) for backup tests. +/// +/// WHY a single fresh-env helper (vs. independent tempdir + writer init +/// per test): a prior plan revision dropped the writer after schema-init +/// then re-started it inside each test, racing migrations under WAL. +/// Tests now reuse the same handle through `TestEnv`. +pub struct TestEnv { + pub tmp: TempDir, + pub writer: SqliteWriterHandle, + pub reads: ReadPool, + pub bus: Arc, +} + +impl TestEnv { + pub fn db_path(&self) -> std::path::PathBuf { + self.tmp.path().join("perima.db") + } +} + +/// Initialise a fresh tempdir + perima.db + writer + read pool. +#[must_use] +pub fn fresh_env() -> TestEnv { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("perima.db"); + let bus = noop_bus(); + let writer = SqliteWriter::start(&db_path, Arc::clone(&bus)).expect("writer start"); + let reads = ReadPool::open(&db_path).expect("pool open"); + TestEnv { + tmp, + writer, + reads, + bus, + } +} + +/// Insert one fake file row. +pub fn insert_one_file_row(env: &TestEnv) { + insert_n_file_rows(env, 1); +} + +/// Insert `n` fake file rows into the `files` table. +/// +/// WHY synthesise hashes deterministically (not via `Blake3Service`): +/// backup tests only need rows to land in `files`; the actual content +/// of `blake3_hash` is never inspected. Deterministic byte-fill keeps +/// the helper allocation-free and makes the seeded-row count the only +/// observable signal. +pub fn insert_n_file_rows(env: &TestEnv, n: usize) { + let repo = SqliteFileRepository::new(env.writer.sender(), env.reads.clone()); + let device = DeviceId::default(); + + for i in 0..n { + // WHY u32 stride + repeat byte: gives a unique 32-byte hash per + // row without needing a hasher dep. usize → u32 → 4-byte LE + + // tail-pad with the same byte produces 256 distinct values for + // small N (the only tests using this seed at most insert 100). + let lo = u32::try_from(i).unwrap_or(u32::MAX); + let mut bytes = [0u8; 32]; + bytes[0..4].copy_from_slice(&lo.to_le_bytes()); + // Pad tail with the low byte so two consecutive indices differ + // even after the leading 4 bytes are zeroed (i < 256 case). + let pad = u8::try_from(lo & 0xFF).unwrap_or(0); + for slot in bytes.iter_mut().skip(4) { + *slot = pad; + } + let hash = BlakeHash::from_bytes(bytes); + + let rel = format!("seed-{i}.bin"); + let hf = HashedFile { + discovered: DiscoveredFile { + absolute_path: env.tmp.path().join(&rel), + relative_path: MediaPath::new(&rel), + size: FileSize(64), + }, + hash, + }; + + repo.upsert_file_with_quick_hash(&hf, device, None) + .expect("upsert seed row"); + } +} + +/// Open a sqlite file read-only and return COUNT(*) FROM files. +/// +/// WHY allow `clippy::disallowed_methods`: backup verification needs to +/// read a non-managed sqlite file (the produced backup) — exactly the +/// legitimate read-only-inspection case carved out by GH #131. +#[allow(clippy::disallowed_methods)] +pub fn count_files(db_path: &Path) -> i64 { + let conn = rusqlite::Connection::open_with_flags( + db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .expect("open ro"); + conn.query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0)) + .expect("count") +} + +/// Test-double `DatabaseAdmin` that holds the in-flight slot for +/// a controlled duration before producing an empty backup file — +/// used by `backup_concurrent_returns_already_in_progress` to make +/// the concurrency window deterministic (not VACUUM-INTO-timing-dependent). +pub struct SlowAdmin { + pub sleep_ms: u64, +} + +impl DatabaseAdmin for SlowAdmin { + fn backup(&self, target: &Path) -> Result { + std::thread::sleep(std::time::Duration::from_millis(self.sleep_ms)); + std::fs::write(target, b"fake-backup") + .map_err(|e| CoreError::Internal(format!("write fake backup: {e}")))?; + Ok(11) + } +} From 39b3defad9abc5cd5bd313d46004db65f3b732eb Mon Sep 17 00:00:00 2001 From: utof Date: Thu, 30 Apr 2026 23:02:34 +0400 Subject: [PATCH 06/11] feat(app): AppDeps + AppContainer wire data_dir + DatabaseAdmin port AppDeps gains (admin: Arc, data_dir: PathBuf). AppContainer constructs Arc from those fields. WHY a DatabaseAdmin port on AppDeps (not a Sender): keeps crates/app's dep graph free of perima-db types; mirrors how files, volumes, tags flow as Arc ports today. Shell wiring (CLI + desktop pass through SqliteDatabaseAdmin instances) lands in the next commit. Refs #168. --- crates/app/src/container.rs | 48 +++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/crates/app/src/container.rs b/crates/app/src/container.rs index edd926c..51ad0f1 100644 --- a/crates/app/src/container.rs +++ b/crates/app/src/container.rs @@ -5,9 +5,10 @@ //! //! - [`AppDeps`] — flat `Arc` DI struct; shells construct //! one directly. -//! - [`AppContainer`] — five `Arc` fields + shared -//! `Arc` (a [`Bus`] under the hood). `Clone` is cheap; -//! axum `with_state` and Tauri `manage` both accept it trivially. +//! - [`AppContainer`] — `Arc` fields (scan, search, tag, volume, +//! metadata, `compute_full_hash`, dedup, backup) + shared `Arc` +//! (a [`Bus`] under the hood). `Clone` is cheap; axum `with_state` and +//! Tauri `manage` both accept it trivially. //! //! # Event-bus wiring (Batch E Task 6) //! @@ -23,7 +24,7 @@ use std::sync::Arc; use perima_core::{ FileRepository, HashService, IdentityCacheRepository, MetadataRepository, Scanner, - SearchRepository, TagRepository, VolumeRepository, events::EventBus, + SearchRepository, TagRepository, VolumeRepository, events::EventBus, ports::DatabaseAdmin, }; use perima_media::ThumbnailGenerator; @@ -41,13 +42,24 @@ use crate::{ /// /// # Field count /// -/// Eight fields (seven repository/service ports + the concrete -/// [`ThumbnailGenerator`]). `ScanUseCase` requires the thumbnailer for -/// post-hash thumbnail generation; since it's a concrete `Arc` and -/// not a `dyn Trait` port, it rides alongside the trait-object ports -/// in this DI struct rather than being stubbed behind a port. +/// Ten fields (one admin port, one path, seven repository/service ports + the +/// concrete [`ThumbnailGenerator`]). `ScanUseCase` requires the thumbnailer +/// for post-hash thumbnail generation; since it's a concrete `Arc` and not +/// a `dyn Trait` port, it rides alongside the trait-object ports in this DI +/// struct rather than being stubbed behind a port. #[derive(Clone)] pub struct AppDeps { + /// Database-engine administration port (slice 1: backup; slice 2/3 + /// of #168 will grow it for vault-sentinel + restore). + /// + /// WHY a port (not a `Sender`): keeps `crates/app`'s dep + /// graph free of `perima-db` types — admins are constructed in + /// shells (`SqliteDatabaseAdmin::new(writer.sender())`) and passed in. + pub admin: Arc, + /// Where the database file (and `backups/` subdir) live. Threaded + /// from each shell's startup-time path resolution + /// (CLI `Config::resolve()`, desktop `resolve_with_app_data_dir()`). + pub data_dir: std::path::PathBuf, /// File + location repository port. pub files: Arc, /// Volume repository port. @@ -95,6 +107,8 @@ impl std::fmt::Debug for AppDeps { /// object keeps the container type stable across those configurations. #[derive(Clone)] pub struct AppContainer { + /// [`crate::BackupDatabaseUseCase`] — on-demand hot backup of the `SQLite` DB. + pub backup: Arc, /// [`ScanUseCase`] — full + incremental scan orchestration. pub scan: Arc, /// [`SearchUseCase`] — FTS5-backed full-text search. @@ -260,6 +274,10 @@ impl AppContainer { Arc::clone(&deps.files), Arc::clone(&events), )); + let backup = Arc::new(crate::BackupDatabaseUseCase::new( + Arc::clone(&deps.admin), + deps.data_dir.clone(), + )); // WHY clone: the same `Arc` lives inside // `VolumeUseCase` (above) AND on the container field so shell @@ -286,6 +304,7 @@ impl AppContainer { let hasher = Arc::clone(&deps.hasher); Arc::new(Self { + backup, scan, search, tag, @@ -315,9 +334,9 @@ mod tests { use perima_core::{AppEvent, FileEvent, MediaPath, VolumeId}; use perima_db::{ - ReadPool, SqliteFileRepository, SqliteIdentityCacheRepository, SqliteMetadataRepository, - SqliteSearchRepository, SqliteTagRepository, SqliteVolumeRepository, SqliteWriter, - SqliteWriterHandle, + ReadPool, SqliteDatabaseAdmin, SqliteFileRepository, SqliteIdentityCacheRepository, + SqliteMetadataRepository, SqliteSearchRepository, SqliteTagRepository, + SqliteVolumeRepository, SqliteWriter, SqliteWriterHandle, }; use perima_fs::WalkdirScanner; use perima_hash::Blake3Service; @@ -391,10 +410,15 @@ mod tests { let hasher: Arc = Arc::new(Blake3Service::new()); let scanner: Arc = Arc::new(WalkdirScanner::new()); let thumbnailer: Arc = Arc::new(ThumbnailGenerator::disabled()); + let admin: Arc = + Arc::new(SqliteDatabaseAdmin::new(writer.sender())); + let data_dir = db_tmp.path().to_path_buf(); ( db_tmp, AppDeps { + admin, + data_dir, files, volumes, tags, From 0c4f6f80ffb82b95403bfaa869b63b1f3f52fdb4 Mon Sep 17 00:00:00 2001 From: utof Date: Thu, 30 Apr 2026 23:18:58 +0400 Subject: [PATCH 07/11] feat(cli,desktop): wire SqliteDatabaseAdmin into AppDeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI threads db_path.parent() as data_dir + a fresh SqliteDatabaseAdmin through to AppContainer. Desktop does the same via build_container. No test sites needed a NoopAdmin stub — handler_spawn.rs does not construct AppDeps. Refs #168. --- crates/cli/src/main.rs | 14 ++++++++++++-- crates/desktop/src/lib.rs | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 87da534..39b9af5 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -30,8 +30,9 @@ use perima_core::{ SearchRepository, TagRepository, VolumeRepository, }; use perima_db::{ - ReadPool, SqliteFileRepository, SqliteIdentityCacheRepository, SqliteMetadataRepository, - SqliteSearchRepository, SqliteTagRepository, SqliteVolumeRepository, SqliteWriter, + ReadPool, SqliteDatabaseAdmin, SqliteFileRepository, SqliteIdentityCacheRepository, + SqliteMetadataRepository, SqliteSearchRepository, SqliteTagRepository, SqliteVolumeRepository, + SqliteWriter, }; use perima_fs::WalkdirScanner; use perima_hash::Blake3Service; @@ -353,7 +354,16 @@ fn build_container( .to_path_buf(), )); + let admin: Arc = + Arc::new(SqliteDatabaseAdmin::new(writer.sender())); + let data_dir = db_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + let deps = AppDeps { + admin, + data_dir, files, volumes, tags, diff --git a/crates/desktop/src/lib.rs b/crates/desktop/src/lib.rs index daa53d6..7a85808 100644 --- a/crates/desktop/src/lib.rs +++ b/crates/desktop/src/lib.rs @@ -32,9 +32,9 @@ use perima_core::{ SearchRepository, TagRepository, VolumeRepository, }; use perima_db::{ - ReadPool, SqliteFileRepository, SqliteIdentityCacheRepository, SqliteMetadataRepository, - SqliteSearchRepository, SqliteTagRepository, SqliteVolumeRepository, SqliteWriter, - SqliteWriterHandle, + ReadPool, SqliteDatabaseAdmin, SqliteFileRepository, SqliteIdentityCacheRepository, + SqliteMetadataRepository, SqliteSearchRepository, SqliteTagRepository, SqliteVolumeRepository, + SqliteWriter, SqliteWriterHandle, }; use perima_fs::WalkdirScanner; use perima_hash::Blake3Service; @@ -398,7 +398,16 @@ fn build_container( .to_path_buf(), )); + let admin: Arc = + Arc::new(SqliteDatabaseAdmin::new(writer.sender())); + let data_dir = db_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + let deps = AppDeps { + admin, + data_dir, files, volumes, tags, From 33b1c4bd83d56fde743feaa2db3d448dc1edb129 Mon Sep 17 00:00:00 2001 From: utof Date: Fri, 1 May 2026 17:11:52 +0400 Subject: [PATCH 08/11] feat(cli): perima backup [--to ] [--force] subcommand Thin shell over BackupDatabaseUseCase. Default path: /backups/perima-.sqlite. --force pre-removes existing target. Prints "Saved to (X.X MB)" on success; "--force" hint on TargetExists failure. Adds assert_cmd + predicates to workspace + cli dev-deps for the 2 assert_cmd integration tests that cover default-path, force, and no-force failure. Refs #168. --- Cargo.lock | 85 ++++++++++++++++++++++ Cargo.toml | 2 + crates/cli/Cargo.toml | 2 + crates/cli/src/cmd/backup.rs | 48 ++++++++++++ crates/cli/src/cmd/mod.rs | 1 + crates/cli/src/main.rs | 34 +++++++++ crates/cli/tests/backup_subcommand_test.rs | 66 +++++++++++++++++ 7 files changed, 238 insertions(+) create mode 100644 crates/cli/src/cmd/backup.rs create mode 100644 crates/cli/tests/backup_subcommand_test.rs diff --git a/Cargo.lock b/Cargo.lock index 0b8da18..da1dd00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -195,6 +195,21 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "assert_cmd" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -441,6 +456,17 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "built" version = "0.8.0" @@ -1052,6 +1078,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -1421,6 +1453,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "flume" version = "0.12.0" @@ -3102,6 +3143,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "notify" version = "8.2.0" @@ -3489,6 +3536,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" name = "perima" version = "0.6.4" dependencies = [ + "assert_cmd", "async-trait", "chrono", "clap", @@ -3504,6 +3552,7 @@ dependencies = [ "perima-fs", "perima-hash", "perima-media", + "predicates", "rayon", "rusqlite", "serde", @@ -3970,6 +4019,36 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -5708,6 +5787,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "textwrap" version = "0.16.2" diff --git a/Cargo.toml b/Cargo.toml index 1072b36..99bfd15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,6 +92,8 @@ clap = { version = "4", features = ["derive"] } tempfile = "3" insta = { version = "1", features = ["yaml", "filters"] } proptest = "1" +assert_cmd = "2" +predicates = "3" rayon = "1" ctrlc = { version = "3", features = ["termination"] } chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index bea6be2..ee5c798 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -40,6 +40,8 @@ tempfile.workspace = true insta.workspace = true rusqlite.workspace = true serde_json.workspace = true +assert_cmd.workspace = true +predicates.workspace = true # WHY image + blake3 in dev-deps: `scan_with_metadata_test.rs` # synthesises PNG/JPEG fixtures at runtime (no binary blobs in git), # matching the pattern already used in `crates/media/tests`. diff --git a/crates/cli/src/cmd/backup.rs b/crates/cli/src/cmd/backup.rs new file mode 100644 index 0000000..2fb99f3 --- /dev/null +++ b/crates/cli/src/cmd/backup.rs @@ -0,0 +1,48 @@ +//! `perima backup` — produce a single-file `SQLite` snapshot of the +//! current database via `VACUUM INTO`. + +use std::path::PathBuf; + +use clap::Parser; +use perima_app::{AppContainer, BackupCommand}; +use perima_core::CoreError; + +/// Arguments for `perima backup`. +#[derive(Parser, Debug)] +pub(crate) struct BackupArgs { + /// Where to write the backup file. + /// Default: `/backups/perima-.sqlite` + #[arg(long)] + pub to: Option, + + /// Overwrite the target if it already exists. + /// Has no effect when `--to` is omitted (default path is timestamped). + #[arg(long)] + pub force: bool, +} + +/// Run the backup subcommand. +/// +/// Delegates to [`BackupDatabaseUseCase::execute`] and prints `"Saved to +/// (X.X MB)"` on success. +/// +/// # Errors +/// +/// Returns [`CoreError::BackupFailed`] if the backup fails; the caller +/// maps to a non-zero exit + stderr message. +/// +/// [`BackupDatabaseUseCase::execute`]: perima_app::BackupDatabaseUseCase::execute +pub(crate) async fn run(container: &AppContainer, args: &BackupArgs) -> Result<(), CoreError> { + let out = container + .backup + .execute(BackupCommand { + target: args.to.clone(), + force: args.force, + }) + .await?; + + #[allow(clippy::cast_precision_loss)] // WHY: MB display precision acceptable for u64 → f64 + let mb = (out.size_bytes as f64) / (1024.0 * 1024.0); + println!("Saved to {} ({:.1} MB)", out.absolute_path.display(), mb); + Ok(()) +} diff --git a/crates/cli/src/cmd/mod.rs b/crates/cli/src/cmd/mod.rs index 8970de2..eefd394 100644 --- a/crates/cli/src/cmd/mod.rs +++ b/crates/cli/src/cmd/mod.rs @@ -1,5 +1,6 @@ //! CLI subcommand modules. +pub(crate) mod backup; pub(crate) mod debug_report; pub(crate) mod dedup; pub(crate) mod format; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 39b9af5..128719b 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -108,6 +108,9 @@ enum Command { tag: Option, }, + /// Produce a single-file `SQLite` snapshot of the database via `VACUUM INTO`. + Backup(cmd::backup::BackupArgs), + /// Tag management: add, remove, and list tags. Tag(cmd::tag::TagArgs), @@ -231,6 +234,8 @@ async fn main() -> ExitCode { tag, } => dispatch_ls(volume, limit, json, with_metadata, tag, &config).await, + Command::Backup(args) => dispatch_backup(&args, &config).await, + Command::Tag(args) => dispatch_tag(&args, &config).await, Command::Hash(args) => dispatch_hash(&args, &config).await, @@ -284,6 +289,35 @@ fn dispatch_migrate_data_dir(args: &cmd::migrate_data_dir::MigrateDataDirArgs) - } } +/// Run the `backup` subcommand. +async fn dispatch_backup(args: &cmd::backup::BackupArgs, config: &Config) -> ExitCode { + let db_path = config.data_dir.join("perima.db"); + let container = match build_container(&db_path, vec![]) { + Ok(c) => c, + Err(e) => { + eprintln!("perima: database: {e}"); + return ExitCode::from(1); + } + }; + match cmd::backup::run(&container, args).await { + Ok(()) => ExitCode::from(0), + Err(perima_core::CoreError::BackupFailed { reason }) => { + eprintln!("perima backup: {reason}"); + if matches!( + reason, + perima_core::errors::BackupFailureReason::TargetExists { .. } + ) { + eprintln!("Pass --force to overwrite."); + } + ExitCode::from(1) + } + Err(e) => { + eprintln!("perima: {e}"); + ExitCode::from(1) + } + } +} + // --------------------------------------------------------------------------- // AppContainer construction // --------------------------------------------------------------------------- diff --git a/crates/cli/tests/backup_subcommand_test.rs b/crates/cli/tests/backup_subcommand_test.rs new file mode 100644 index 0000000..1110af4 --- /dev/null +++ b/crates/cli/tests/backup_subcommand_test.rs @@ -0,0 +1,66 @@ +//! `perima backup` subcommand integration test. +//! +//! WHY subprocess-based: verifies the full dispatch path including DB schema +//! migrations and clap argument parsing through the real binary entry point. + +#![allow(clippy::unwrap_used)] + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +fn perima_cmd(data_dir: &std::path::Path, config_dir: &std::path::Path) -> Command { + let mut cmd = Command::cargo_bin("perima").expect("bin"); + cmd.env("PERIMA_DATA_DIR", data_dir); + cmd.env("PERIMA_CONFIG_DIR", config_dir); + cmd +} + +#[test] +fn backup_default_path_succeeds_and_prints_path() { + let tmp = TempDir::new().expect("tempdir"); + let data_dir = tmp.path().join("data"); + let config_dir = tmp.path().join("config"); + std::fs::create_dir_all(&data_dir).expect("data dir"); + std::fs::create_dir_all(&config_dir).expect("config dir"); + + perima_cmd(&data_dir, &config_dir) + .args(["backup"]) + .assert() + .success() + .stdout(predicate::str::contains("Saved to")) + .stdout(predicate::str::contains("backups")) + .stdout(predicate::str::contains(".sqlite")); + + let backups_dir = data_dir.join("backups"); + let entries: Vec<_> = std::fs::read_dir(&backups_dir) + .expect("read backups dir") + .collect(); + assert!(!entries.is_empty(), "default backup file should exist"); +} + +#[test] +fn backup_to_existing_path_without_force_fails() { + let tmp = TempDir::new().expect("tempdir"); + let data_dir = tmp.path().join("data"); + let config_dir = tmp.path().join("config"); + std::fs::create_dir_all(&data_dir).expect("data dir"); + std::fs::create_dir_all(&config_dir).expect("config dir"); + + let target = tmp.path().join("out.sqlite"); + perima_cmd(&data_dir, &config_dir) + .args(["backup", "--to", target.to_str().unwrap()]) + .assert() + .success(); + + perima_cmd(&data_dir, &config_dir) + .args(["backup", "--to", target.to_str().unwrap()]) + .assert() + .failure() + .stderr(predicate::str::contains("--force")); + + perima_cmd(&data_dir, &config_dir) + .args(["backup", "--to", target.to_str().unwrap(), "--force"]) + .assert() + .success(); +} From ec039ad7c182cf9f5bff85677ddf028aa5fba0fd Mon Sep 17 00:00:00 2001 From: utof Date: Fri, 1 May 2026 17:35:49 +0400 Subject: [PATCH 09/11] feat(desktop): backup_database IPC command + specta bindings Tauri handler delegating to BackupDatabaseUseCase via AppContainer. Wired into the tauri-specta `collect_commands!` list (the production path is `specta_builder.invoke_handler()`; lib.rs has no separate `tauri::generate_handler!`) so bindings.ts emits the typed BackupOutput + inline BackupFailureReason payload of CoreError. bindings_compile asserts BackupOutput as a top-level TS type and BackupFailureReason::TargetExists variant as a substring inside the inline CoreError union (quote-style tolerant for forward-compat). Refs #168. --- apps/desktop/src/bindings.ts | 29 ++++++++++++++++- crates/desktop/src/commands.rs | 41 ++++++++++++++++++++++++ crates/desktop/src/lib.rs | 1 + crates/desktop/tests/bindings_compile.rs | 27 ++++++++++++---- 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/bindings.ts b/apps/desktop/src/bindings.ts index 754c2ee..f32459d 100644 --- a/apps/desktop/src/bindings.ts +++ b/apps/desktop/src/bindings.ts @@ -148,7 +148,8 @@ export type CoreError = | { kind: "Io"; data: { kind: string; message: string } } | { kind: "Unsupported"; data: string } | { kind: "Internal"; data: string } - | { kind: "FullHashUnavailable"; data: { reason: FullHashUnavailableReason } }; + | { kind: "FullHashUnavailable"; data: { reason: FullHashUnavailableReason } } + | { kind: "BackupFailed"; data: { reason: BackupFailureReason } }; // ── Structs ────────────────────────────────────────────────────────── @@ -344,3 +345,29 @@ export type CollisionGroup = { files: FileLocationRecord[]; verified_state: VerifiedState; }; + +// ── Backup types (slice 1, GH #168) ────────────────────────────────── + +/** + * Typed reasons a `BackupDatabaseUseCase::execute` call can fail. + * Inner payload of `CoreError::BackupFailed`. + * Rust: `BackupFailureReason` with `#[serde(tag = "kind", content = "data")]` + * (per `crates/core/src/errors.rs`). + */ +export type BackupFailureReason = + | { kind: "TargetExists"; data: { path: string } } + | { kind: "TargetUnwritable"; data: { path: string; message: string } } + | { kind: "DiskFull"; data: { path: string } } + | { kind: "AlreadyInProgress" } + | { kind: "Internal"; data: string }; + +/** + * Successful output of `backup_database` / `BackupDatabaseUseCase::execute`. + * Rust: `BackupOutput` in `crates/app/src/backup.rs`. + */ +export type BackupOutput = { + /** Absolute path to the freshly written backup file. */ + absolute_path: string; + /** Size in bytes of the freshly written backup file. */ + size_bytes: number; +}; diff --git a/crates/desktop/src/commands.rs b/crates/desktop/src/commands.rs index ceefd2c..e62c598 100644 --- a/crates/desktop/src/commands.rs +++ b/crates/desktop/src/commands.rs @@ -1601,3 +1601,44 @@ pub async fn mark_verified_distinct( .dedup .mark_verified_distinct(file_uuids, state.device_id) } + +// --------------------------------------------------------------------------- +// Backup commands (slice 1, GH #168) +// --------------------------------------------------------------------------- + +/// Produce a single-file consistent `SQLite` snapshot of the database. +/// +/// Delegates to [`perima_app::BackupDatabaseUseCase`], which resolves the +/// target path (`/backups/perima-.sqlite` when `target` +/// is `None`), enforces single-flight via an `AtomicBool` guard, +/// pre-removes when `force` is `true`, and dispatches the actual copy +/// through [`perima_core::ports::DatabaseAdmin`] (writer-actor `VACUUM INTO`). +/// +/// Returns a [`perima_app::BackupOutput`] carrying the absolute path +/// written to plus the size in bytes of the freshly written file. +/// +/// # Errors +/// Returns [`CoreError::BackupFailed`] with a typed +/// [`perima_core::errors::BackupFailureReason`]: +/// - `TargetExists` — `target` already exists and `force` was not passed. +/// - `TargetUnwritable` — parent directory could not be created or +/// pre-existing file could not be removed. +/// - `AlreadyInProgress` — another backup is currently running on this +/// process. +/// - `DiskFull` / `Internal(...)` — propagated from the writer-actor +/// adapter on lower-level `SQLite` failures. +// WHY allow: Tauri owns `State` and value params (Option + bool). +#[allow(clippy::needless_pass_by_value)] +#[tauri::command] +#[specta::specta] +pub async fn backup_database( + state: tauri::State<'_, AppState>, + target: Option, + force: bool, +) -> Result { + state + .container + .backup + .execute(perima_app::BackupCommand { target, force }) + .await +} diff --git a/crates/desktop/src/lib.rs b/crates/desktop/src/lib.rs index 7a85808..98b42c0 100644 --- a/crates/desktop/src/lib.rs +++ b/crates/desktop/src/lib.rs @@ -117,6 +117,7 @@ pub fn run() -> Result<(), RunError> { commands::cancel_verify_batch, commands::list_quick_hash_collisions, commands::mark_verified_distinct, + commands::backup_database, ]); // Export TypeScript bindings when the `specta-export` feature is diff --git a/crates/desktop/tests/bindings_compile.rs b/crates/desktop/tests/bindings_compile.rs index 2f92bc4..5906bf5 100644 --- a/crates/desktop/tests/bindings_compile.rs +++ b/crates/desktop/tests/bindings_compile.rs @@ -34,8 +34,9 @@ use tauri_specta::{Builder, collect_commands}; use perima_desktop::commands; -/// Builds the same 13-command tauri-specta `Builder` that `lib.rs::run` -/// constructs. Centralised so future handler renames or additions are +/// Builds the same tauri-specta `Builder` that `lib.rs::run` +/// constructs (subset of the production command set, kept in sync +/// manually). Centralised so future handler renames or additions are /// caught loudly at compile time in exactly one place. fn build_test_builder() -> Builder { Builder::::new().commands(collect_commands![ @@ -52,20 +53,21 @@ fn build_test_builder() -> Builder { commands::list_files_with_tags, commands::search, commands::search_rebuild, + commands::backup_database, ]) } -/// Verifies that the tauri-specta builder accepts all 13 IPC commands -/// and produces a non-empty TypeScript file containing every core -/// domain type that crosses the IPC boundary as a command argument or -/// return value. +/// Verifies that the tauri-specta builder accepts the registered IPC +/// commands and produces a non-empty TypeScript file containing every +/// core domain type that crosses the IPC boundary as a command argument +/// or return value. /// /// Coverage rationale: `CoreError` (Result error on every handler); /// `ScanReport` (scan handler return); `FileLocationRecord` / /// `VolumeRecord` / `Tag` / `SearchHit` (handler returns); `BlakeHash` /// (transitive field of `FileLocationRecord`); composite payloads /// `FileWithMetadataPayload` + `FileWithTagsPayload` (retained per -/// spec §8 #6). +/// spec §8 #6); `BackupOutput` (`backup_database` return, slice 1). /// /// WHY `AppEvent` / `FileEvent` / `InvalidationReason` are NOT asserted /// here: those types cross the IPC boundary via the `"app-event"` Tauri @@ -101,6 +103,7 @@ fn tauri_specta_builder_exports_full_ipc_type_graph() { "VolumeRecord", "SearchHit", "BlakeHash", + "BackupOutput", ] { assert!(ts.contains(ty), "{ty} missing from bindings"); } @@ -118,4 +121,14 @@ fn tauri_specta_builder_exports_full_ipc_type_graph() { for ty in ["FileWithMetadataPayload", "FileWithTagsPayload"] { assert!(ts.contains(ty), "{ty} missing from bindings"); } + + // BackupFailureReason appears as inline payload of CoreError::BackupFailed, + // not as a top-level type. tauri-specta emits double-quoted TS literals + // for #[serde(tag = "kind", content = "data")] string discriminants; + // assert one variant tag substring (quote style is stable per the + // committed bindings.ts; accept either form for forward-compat). + assert!( + ts.contains(r#"kind: "TargetExists""#) || ts.contains("kind: \"TargetExists\""), + "BackupFailureReason::TargetExists variant missing from bindings" + ); } From 04a3244a20bd177d272866e4570cb20e6661f063 Mon Sep 17 00:00:00 2001 From: utof Date: Fri, 1 May 2026 17:47:32 +0400 Subject: [PATCH 10/11] feat(desktop/web): api.backupDatabase + useBackupDatabase mutation hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResultAsync wrapper bridging neverthrow → React Query exception contract. coreErrorMessage extended with BackupFailureReason discriminated branch — TS-exhaustive never default catches future variant additions at compile time. StatusBar.tsx errorKindLabel extended with BackupFailed case per CLAUDE.md IPC boundary contract (exhaustiveness check). WHY no invalidateQueries in the mutation: backup is a side-effect file producer with no frontend cache observing DB rows differently. Future "recent backups" panel would invalidate its own key here. Refs #168. --- apps/desktop/src/api.ts | 26 +++++++++++++ apps/desktop/src/components/StatusBar.tsx | 26 ++++++++++++- apps/desktop/src/lib/coreError.ts | 24 +++++++++++- apps/desktop/src/queries/backup.ts | 45 +++++++++++++++++++++++ 4 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/queries/backup.ts diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 5cc0d75..601221d 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -12,6 +12,7 @@ import { listen } from "@tauri-apps/api/event"; import { ResultAsync } from "neverthrow"; import type { AppEvent, + BackupOutput, BatchHandle, BatchId, BlakeHash, @@ -44,6 +45,7 @@ const KNOWN_KINDS: ReadonlySet = new Set([ "Unsupported", "Internal", "FullHashUnavailable", + "BackupFailed", ]); /** @@ -331,3 +333,27 @@ export function markVerifiedDistinct( ): ResultAsync { return fromInvoke("mark_verified_distinct", { fileUuids }); } + +/** + * Backup the SQLite database to `target` (absolute path). + * + * When `target` is omitted, the backend chooses a timestamped path under + * `/backups/`. When `force` is true, an existing file at `target` + * is silently overwritten (otherwise `CoreError::BackupFailed` with reason + * `TargetExists` is returned). + * + * WHY `target?: string` (not required): callers that only want a safe + * one-click backup need not know or construct a path. + * + * @param target - Optional absolute path for the backup file. + * @param force - Overwrite an existing file at `target`. Default false. + */ +export function backupDatabase( + target?: string, + force = false, +): ResultAsync { + return fromInvoke("backup_database", { + target: target ?? null, + force, + }); +} diff --git a/apps/desktop/src/components/StatusBar.tsx b/apps/desktop/src/components/StatusBar.tsx index 45f38f4..fa85a3f 100644 --- a/apps/desktop/src/components/StatusBar.tsx +++ b/apps/desktop/src/components/StatusBar.tsx @@ -20,7 +20,7 @@ import { useShallow } from "zustand/shallow"; import { useUiStore } from "../stores/ui"; import { useCollisions } from "../queries/dedup"; import CollisionPill from "./CollisionPill"; -import type { CoreError, FullHashUnavailableReason } from "../bindings"; +import type { BackupFailureReason, CoreError, FullHashUnavailableReason } from "../bindings"; /** * Returns a short human-readable label for a {@link CoreError}, branching on @@ -50,6 +50,8 @@ export function errorKindLabel(err: CoreError): string { return `Internal error: ${err.data}`; case "FullHashUnavailable": return `Full hash unavailable: ${fullHashUnavailableReasonLabel(err.data.reason)}`; + case "BackupFailed": + return `Backup failed: ${backupFailureReasonLabel(err.data.reason)}`; default: { // WHY never: TypeScript exhaustiveness check. If a new CoreError variant // is added to bindings.ts without a matching case above, this line @@ -60,6 +62,28 @@ export function errorKindLabel(err: CoreError): string { } } +/** + * Returns a short label for a {@link BackupFailureReason}. + */ +function backupFailureReasonLabel(reason: BackupFailureReason): string { + switch (reason.kind) { + case "TargetExists": + return `file already exists at ${reason.data.path}`; + case "TargetUnwritable": + return `cannot write to ${reason.data.path}: ${reason.data.message}`; + case "DiskFull": + return `disk full at ${reason.data.path}`; + case "AlreadyInProgress": + return "already in progress"; + case "Internal": + return reason.data; + default: { + const _exhaustive: never = reason; + return String(_exhaustive); + } + } +} + /** * Returns a short label for a {@link FullHashUnavailableReason}. */ diff --git a/apps/desktop/src/lib/coreError.ts b/apps/desktop/src/lib/coreError.ts index 66fef47..011ab20 100644 --- a/apps/desktop/src/lib/coreError.ts +++ b/apps/desktop/src/lib/coreError.ts @@ -1,4 +1,4 @@ -import type { CoreError, FullHashUnavailableReason } from "../bindings"; +import type { BackupFailureReason, CoreError, FullHashUnavailableReason } from "../bindings"; /** * Returns a human-readable string from a {@link CoreError} data payload. @@ -13,6 +13,9 @@ export function coreErrorMessage(e: CoreError): string { if (e.kind === "FullHashUnavailable") { return fullHashUnavailableMessage(e.data.reason); } + if (e.kind === "BackupFailed") { + return backupFailureMessage(e.data.reason); + } if (typeof e.data === "string") { return e.data; } @@ -35,3 +38,22 @@ function fullHashUnavailableMessage(reason: FullHashUnavailableReason): string { return `Full hash unavailable: I/O error (${reason.message}).`; } } + +function backupFailureMessage(reason: BackupFailureReason): string { + switch (reason.kind) { + case "TargetExists": + return `Backup file already exists at ${reason.data.path}. Pass --force to overwrite, or pick a different path.`; + case "TargetUnwritable": + return `Cannot write backup at ${reason.data.path}: ${reason.data.message}`; + case "DiskFull": + return `Disk is full; cannot write backup to ${reason.data.path}.`; + case "AlreadyInProgress": + return "A backup is already running. Try again when it finishes."; + case "Internal": + return `Internal backup error: ${reason.data}`; + default: { + const _exhaustive: never = reason; + return _exhaustive; + } + } +} diff --git a/apps/desktop/src/queries/backup.ts b/apps/desktop/src/queries/backup.ts new file mode 100644 index 0000000..10313ec --- /dev/null +++ b/apps/desktop/src/queries/backup.ts @@ -0,0 +1,45 @@ +/** + * `useBackupDatabase` — TanStack Query mutation hook for `backup_database` IPC. + * + * WHY no `invalidateQueries` call: backup is a read-only-from-the-frontend + * operation — it produces a side-effect file but no frontend cache observes + * DB rows differently after a backup. If a future slice (e.g. a "list of + * recent backups" panel) introduces a queryKey that mirrors filesystem + * state under `/backups/`, invalidate that key here. + */ + +import { useMutation } from "@tanstack/react-query"; + +import * as api from "../api"; +import type { CoreError } from "../bindings"; +import { coreErrorMessage } from "../lib/coreError"; +import { useUiStore } from "../stores/ui"; + +export const backupKeys = { all: ["backup"] as const }; + +interface BackupVars { + target?: string; + force?: boolean; +} + +export function useBackupDatabase() { + const notify = useUiStore((s) => s.notify); + return useMutation({ + mutationKey: backupKeys.all, + mutationFn: async (vars: BackupVars) => + api.backupDatabase(vars.target, vars.force ?? false).match( + (out) => out, + (err) => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw err; + }, + ), + onSuccess: (out) => { + const mb = (out.size_bytes / (1024 * 1024)).toFixed(1); + notify("info", `Backup saved to ${out.absolute_path} (${mb} MB)`); + }, + onError: (err: CoreError) => { + notify("error", `Backup failed: ${coreErrorMessage(err)}`); + }, + }); +} From fe784db78a6144b8d0f385a06fd8ba08938079af Mon Sep 17 00:00:00 2001 From: utof Date: Fri, 1 May 2026 17:57:35 +0400 Subject: [PATCH 11/11] =?UTF-8?q?feat(desktop):=20StatusBar=20Backup=20but?= =?UTF-8?q?ton=20=E2=80=94=20one-click=20DB=20snapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click → useMutation(api.backupDatabase) → toast on success/failure. disabled={isPending} disables the same-instance button during the backup; the BackupFailureReason::AlreadyInProgress backend guard serves as the source of truth for cross-instance + CLI-vs-UI races. 2 vitest tests verify the success-toast (path + MB) and the typed BackupFailed → "already exists" error-toast paths. Mocks `../api` (not `@tauri-apps/api/core` directly) per the canonical perima frontend test pattern using neverthrow `okAsync`/`errAsync`. Closes #168 slice 1. --- .../src/__tests__/StatusBar.backup.test.tsx | 99 +++++++++++++++++++ apps/desktop/src/components/StatusBar.tsx | 18 +++- 2 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/__tests__/StatusBar.backup.test.tsx diff --git a/apps/desktop/src/__tests__/StatusBar.backup.test.tsx b/apps/desktop/src/__tests__/StatusBar.backup.test.tsx new file mode 100644 index 0000000..59ddf72 --- /dev/null +++ b/apps/desktop/src/__tests__/StatusBar.backup.test.tsx @@ -0,0 +1,99 @@ +/** + * StatusBar Backup button — Task 9 of the database-backup slice. + * + * Branches under test: + * - Click Backup → api.backupDatabase resolves Ok → info toast with + * absolute_path and formatted MB. + * - Click Backup → api.backupDatabase resolves Err(BackupFailed/TargetExists) + * → error toast that includes "already exists". + * + * WHY mock `../api` (not `@tauri-apps/api/core`): the perima frontend test + * pattern mocks the API layer with `vi.importActual` partial spread, returning + * `okAsync`/`errAsync` from neverthrow. Mocking `invoke` directly bypasses + * `fromInvoke`'s `parseCoreError` and produces a different shape than + * `useBackupDatabase`'s `.match(...)` branch consumes. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { errAsync, okAsync } from "neverthrow"; +import StatusBar from "../components/StatusBar"; +import * as api from "../api"; +import { useUiStore } from "../stores/ui"; +import { renderWithProviders, resetUiStore } from "./test-utils"; +import type { CoreError } from "../bindings"; + +vi.mock("../api", async () => { + const actual = await vi.importActual("../api"); + return { + ...actual, + backupDatabase: vi.fn(), + // WHY listQuickHashCollisions: StatusBar renders which + // calls useCollisions() → listQuickHashCollisions. Without a mock the + // real `invoke` fires, which fails in jsdom. Return an empty list so the + // pill renders in its neutral "0 groups" state without affecting the + // backup-button assertions. + listQuickHashCollisions: vi.fn(), + }; +}); + +const mockBackupDatabase = vi.mocked(api.backupDatabase); +const mockListCollisions = vi.mocked(api.listQuickHashCollisions); + +beforeEach(() => { + vi.clearAllMocks(); + resetUiStore(); + mockListCollisions.mockReturnValue(okAsync([])); +}); + +describe("StatusBar Backup button", () => { + it("notifies success with path + MB on Ok", async () => { + mockBackupDatabase.mockReturnValueOnce( + okAsync({ + absolute_path: "/tmp/backup.sqlite", + size_bytes: 1024 * 1024 * 5, // 5 MB + }), + ); + + renderWithProviders(); + + fireEvent.click(screen.getByRole("button", { name: /backup/i })); + + await waitFor(() => { + const notifications = useUiStore.getState().notifications; + expect( + notifications.some( + (n) => + n.kind === "info" && + n.message.includes("/tmp/backup.sqlite") && + n.message.includes("5.0 MB"), + ), + ).toBe(true); + }); + }); + + it("notifies typed error on TargetExists", async () => { + const err: CoreError = { + kind: "BackupFailed", + data: { + reason: { + kind: "TargetExists", + data: { path: "/tmp/backup.sqlite" }, + }, + }, + }; + mockBackupDatabase.mockReturnValueOnce(errAsync(err)); + + renderWithProviders(); + + fireEvent.click(screen.getByRole("button", { name: /backup/i })); + + await waitFor(() => { + const notifications = useUiStore.getState().notifications; + expect( + notifications.some( + (n) => n.kind === "error" && n.message.includes("already exists"), + ), + ).toBe(true); + }); + }); +}); diff --git a/apps/desktop/src/components/StatusBar.tsx b/apps/desktop/src/components/StatusBar.tsx index fa85a3f..8537ad3 100644 --- a/apps/desktop/src/components/StatusBar.tsx +++ b/apps/desktop/src/components/StatusBar.tsx @@ -19,6 +19,7 @@ import { useShallow } from "zustand/shallow"; import { useUiStore } from "../stores/ui"; import { useCollisions } from "../queries/dedup"; +import { useBackupDatabase } from "../queries/backup"; import CollisionPill from "./CollisionPill"; import type { BackupFailureReason, CoreError, FullHashUnavailableReason } from "../bindings"; @@ -110,6 +111,7 @@ export default function StatusBar() { // so CollisionPill renders the neutral "no candidate duplicates" state // rather than crashing on undefined. const { data: collisions = [] } = useCollisions(); + const backupMutation = useBackupDatabase(); let summary: string; if (status === "scanning") { @@ -121,9 +123,21 @@ export default function StatusBar() { } return ( -
+
{summary} - +
+ {/* WHY type="button": prevents form submission if StatusBar is ever + rendered inside a form element. */} + + +
); }