Skip to content

feat: DB backup primitive (slice 1 of #168)#172

Merged
utof merged 11 commits into
mainfrom
backup-slice-1/v0.6.x
May 1, 2026
Merged

feat: DB backup primitive (slice 1 of #168)#172
utof merged 11 commits into
mainfrom
backup-slice-1/v0.6.x

Conversation

@utof

@utof utof commented May 1, 2026

Copy link
Copy Markdown
Owner

Summary

  • New perima backup [--to <path>] [--force] CLI subcommand
  • New backup_database Tauri IPC command + StatusBar "Backup" button
  • New CoreError::BackupFailed { reason: BackupFailureReason } discriminated error variant (5 reason kinds)
  • New DatabaseAdmin port + SqliteDatabaseAdmin adapter
  • New BackupDatabaseUseCase with AtomicBool in-flight guard

Behavior

  • Default target: <data_dir>/backups/perima-<UTC ISO 8601>.sqlite — timestamped so default-path backups stack indefinitely without overwrite.
  • --force pre-removes the target at the use-case level (writer never sees force).
  • VACUUM INTO runs through the writer thread; serializes naturally with other writes — no DB lock contention with concurrent reads or writes.
  • AlreadyInProgress error variant + AtomicBool guard prevent double-click + cross-shell (CLI vs UI) backup races.
  • Frontend: typed BackupFailureReason discriminated union with TS-exhaustive never defaults so future variants surface as compile errors.

Tests added

  • 5 wire-shape tests (crates/core/tests/serialize_shape.rs) pinning the JSON shape of CoreError::BackupFailed for each reason variant.
  • 6 integration tests at the use-case level (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.
  • 2 CLI integration tests (crates/cli/tests/backup_subcommand_test.rs) using assert_cmd — happy-path + force-rejection + force-override flows.
  • 2 vitest tests (apps/desktop/src/__tests__/StatusBar.backup.test.tsx) covering success-toast and TargetExists error-toast paths.
  • New crates/app/tests/common/mod.rs shared helper module (slice 1 introduces this convention to the perima-app crate).

Test plan

  • CI green on Linux + macOS + Windows
  • Manual: bun run dev, click "Backup" in the StatusBar, verify the toast shows "Backup saved to (X.X MB)" and the file opens with sqlite3 <path> ".tables".
  • Manual: perima backup from CLI, verify a timestamped file appears under <data_dir>/backups/, then perima backup --to <existing> rejects with the --force hint, then perima backup --to <existing> --force overwrites.

Refs #168.

utof added 11 commits April 30, 2026 22:01
…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.
@utof
utof merged commit ff980c0 into main May 1, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant