feat: DB backup primitive (slice 1 of #168)#172
Merged
Conversation
…dmin 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.
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.
…heck 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.
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.
Online single-file backup orchestration via the writer-actor + DatabaseAdmin port. Default target stacks indefinitely via <data_dir>/backups/perima-<UTC ISO>.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 <data_dir>/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.
AppDeps gains (admin: Arc<dyn DatabaseAdmin>, data_dir: PathBuf). AppContainer constructs Arc<BackupDatabaseUseCase> from those fields. WHY a DatabaseAdmin port on AppDeps (not a Sender<WriteCmd>): keeps crates/app's dep graph free of perima-db types; mirrors how files, volumes, tags flow as Arc<dyn ...> ports today. Shell wiring (CLI + desktop pass through SqliteDatabaseAdmin instances) lands in the next commit. Refs #168.
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.
Thin shell over BackupDatabaseUseCase. Default path: <data_dir>/backups/perima-<UTC ISO>.sqlite. --force pre-removes existing target. Prints "Saved to <path> (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.
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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
perima backup [--to <path>] [--force]CLI subcommandbackup_databaseTauri IPC command + StatusBar "Backup" buttonCoreError::BackupFailed { reason: BackupFailureReason }discriminated error variant (5 reason kinds)DatabaseAdminport +SqliteDatabaseAdminadapterBackupDatabaseUseCasewithAtomicBoolin-flight guardBehavior
<data_dir>/backups/perima-<UTC ISO 8601>.sqlite— timestamped so default-path backups stack indefinitely without overwrite.--forcepre-removes the target at the use-case level (writer never seesforce).VACUUM INTOruns through the writer thread; serializes naturally with other writes — no DB lock contention with concurrent reads or writes.AlreadyInProgresserror variant +AtomicBoolguard prevent double-click + cross-shell (CLI vs UI) backup races.BackupFailureReasondiscriminated union with TS-exhaustiveneverdefaults so future variants surface as compile errors.Tests added
crates/core/tests/serialize_shape.rs) pinning the JSON shape ofCoreError::BackupFailedfor each reason variant.crates/app/tests/backup_*.rs) covering: valid-SQLite output, writer resumes after backup, serialization with concurrent pending writes, default-path resolution, in-flight guard rejection + slot release, and target-unwritable mapping.crates/cli/tests/backup_subcommand_test.rs) usingassert_cmd— happy-path + force-rejection + force-override flows.apps/desktop/src/__tests__/StatusBar.backup.test.tsx) covering success-toast and TargetExists error-toast paths.crates/app/tests/common/mod.rsshared helper module (slice 1 introduces this convention to theperima-appcrate).Test plan
bun run dev, click "Backup" in the StatusBar, verify the toast shows "Backup saved to (X.X MB)" and the file opens withsqlite3 <path> ".tables".perima backupfrom CLI, verify a timestamped file appears under<data_dir>/backups/, thenperima backup --to <existing>rejects with the--forcehint, thenperima backup --to <existing> --forceoverwrites.Refs #168.