diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..c76bbc3 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,12 @@ +# cargo-nextest configuration +# https://nexte.st/docs/configuration/ + +[profile.default] +# Flag a test as "slow" after 40s and terminate after two slow periods +# (80s total per test). Rationale: the slowest clean test in any crate +# is ~14s (fts proptest in perima-db). Past 40s is a sign of lock +# contention or deadlock, not legitimate compute. nextest 0.9.133 lacks +# a CLI `--slow-timeout` flag, but honors this config key — keeps +# `timeout NNN cargo nextest ...` wrappers from killing the whole suite +# on a single hang. +slow-timeout = { period = "40s", terminate-after = 2 } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a43c15..b1e67f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,12 +29,17 @@ jobs: - uses: taiki-e/install-action@just - # WHY: `just ci` runs `cargo deny check` and `typos`. Both are - # external binaries — neither ships with the Rust toolchain. Using - # taiki-e/install-action fetches prebuilt binaries (seconds) rather - # than `cargo install` (minutes), which matters on the 3-OS matrix. + # WHY: `just ci` runs `cargo deny check`, `typos`, and + # `cargo nextest run`. All three are external binaries — none ship + # with the Rust toolchain. Using taiki-e/install-action fetches + # prebuilt binaries (seconds) rather than `cargo install` (minutes), + # which matters on the 3-OS matrix. Nextest swap landed in commit + # adopting `cargo nextest run` workspace-wide (closes #121) — the + # SQLite lock-order inversion that `cargo test` reproduces ~20% is + # documented in CLAUDE.md + GH #131. - uses: taiki-e/install-action@cargo-deny - uses: taiki-e/install-action@typos + - uses: taiki-e/install-action@cargo-nextest - uses: actions/setup-node@v4 with: @@ -61,3 +66,36 @@ jobs: - name: Run just ci shell: bash run: just ci + + bindings-drift: + name: bindings.ts drift check + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - uses: taiki-e/install-action@just + + # WHY: same Tauri Linux deps as the matrix job's Linux branch — + # tauri-build links against GTK3 + WebKit2GTK on Linux. + - name: Install Tauri Linux deps + run: | + sudo apt-get update -q + sudo apt-get install -yq \ + libgtk-3-dev \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf + + # WHY: regenerate bindings.ts via the specta-export feature and + # fail the job if `git diff` shows the committed copy is stale. + # Catches every Rust-side change to a #[tauri::command] signature, + # a specta-derived type, or a CoreError variant that wasn't paired + # with a bindings.ts commit. + - name: Bindings drift check + run: just bindings diff --git a/CLAUDE.md b/CLAUDE.md index e797a36..d960875 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,8 @@ steering, not interruption. Chip one task at a time. 6. `superpowers:verification-before-completion` before claiming done. 7. `superpowers:test-driven-development` throughout. +If you have just been compacted, load the skills appropriate for the current task. The most common and important ones: mid-execution - subagent-dr-dev. Pre: brainstorm. + Correctness rides on tests (no human e2e feedback): unit + integration always, e2e where feasible. Spec/plan drafts stay uncommitted during review — collapses N revision commits into one clean commit and keeps @@ -37,26 +39,64 @@ adapters. `crates/{desktop,api,cli,ffi}` wire adapters to core. `justfile`; reintroduce `crates/xtask` only when shell scripts stop scaling. ## Tooling - - JS/TS: **always `bun`** unless incompatible. - Rust: `cargo`, `cargo clippy`, `cargo test`, `cargo doc`. `rusqlite` (bundled), not sqlx. `thiserror` in libs, `anyhow` in apps. - `justfile` at root (Rust `xtask` deferred). -- **`codebase-memory-mcp`** — prefer over Grep for cross-crate - structural questions ("who calls X?", "what depends on Y?"). -- **`LSP` (rust-analyzer, deferred)** — `ToolSearch select:LSP`. - Prefer over grep for Rust symbol queries; semantic-index-backed so no - comment/cross-language false matches. Fall back to Grep for docs/comments. +- **`codebase-memory-mcp`** — perima is indexed (project name + `media-vboxuser-G-samsung1-0utoffiles-code-perima`). Tools: + `mcp__codebase-memory-mcp__{query_graph, trace_call_path, search_code, + search_graph, get_architecture, index_status}`. Reach for it on + "who calls X?", "what breaks if I rename Y?", "crates depending on + Z?", "find all Tauri command handlers". Skip for raw text in + docs/comments or known file paths. **Dispatch subagents with the MCP + note** — they inherit the tools; include "`mcp__codebase-memory-mcp__*` + available; prefer over grep for structural queries" in their prompt. +- LSP rust-analyzer — `ToolSearch select:LSP`. + Rust-only semantic symbol queries; prefer over grep for Rust names. + Grep last resort +- In doubt of api and implementations, use context7, deepwiki, and web search, to find latest docs. Things include: crates, ts/react libraries, best practices, test libraries, e2e, DBs, react practices and anti-patterns. ## Test stack (pinned, don't re-litigate) - -- Rust: `cargo test` + `insta` (snapshots) + `proptest` (hash +- Rust: **`cargo nextest run`** + `insta` (snapshots) + `proptest` (hash determinism, path round-trip). Integration tests hit real SQLite. + Doctests still go through `cargo test --doc` (nextest doesn't run them). +- **NEVER use `cargo test` for non-doc tests** (banned repo-wide via + `scripts/no-cargo-test.sh`, enforced in `just ci` + lefthook pre-push). + WHY: SQLite has a lock-order inversion in `unixClose` (GLOBAL→PER-INODE) + vs `unixLock` from `sqlite3WalClose` (PER-INODE→GLOBAL) — concurrent + Connection drops on the same DB file deadlock. `cargo test` reproduces + it ~20% of runs (intermittent silent hang); `cargo nextest run`'s + process-per-test isolation eliminates it. See `RESEARCH-sqlite-deadlock.md` + + GH #121. Reproduced 2026-04-23 with two gdb backtraces. - TS: `bun test` units; `vitest` + jsdom for React; Playwright only when a phase ships UI worth e2e-ing. -## Doc discipline (strict, enforced in CI) +## Test timing baselines (cold compile, `-j 2`, as of 532ac2a / 2026-04-22) +Set subagent `timeout NNN` wrappers at **~2× expected**, not blanket 600s. +A too-loose timeout wastes 5-10 min per failed attempt. + +- `cargo nextest run -p perima-db --test-threads 2`: **~15s** (slowest single + test `fts_consistent_under_tag_churn` ~14s — proptest) +- `cargo nextest run -p perima-app --test-threads 2`: **~5-10s** (23 tests) +- `cargo nextest run -p perima-cli --test-threads 2`: **<10s** +- `cargo nextest run --workspace --exclude perima-desktop -j 2`: **<60s** clean +- `cargo nextest run -p perima-desktop`: **120-300s** (Tauri compile dominates) +- `cargo clippy --workspace --exclude perima-desktop -j 2 -- -D warnings`: **30-90s** + +Nextest 0.9.133 (latest as of 2026-04-22) lacks a `--slow-timeout` CLI +flag, but honors the equivalent config key. `.config/nextest.toml` +committed at repo root with `[profile.default] slow-timeout = { period += "60s", terminate-after = 2 }` — any test past 120s self-aborts so +outer `timeout NNN` wrappers don't kill the whole suite on one hang. + +## Long-running commands +- Wrap workspace-wide `cargo {nextest,build,run}` in `timeout 600`. Trip = deadlock signal, NOT retry trigger. +- **`cargo nextest run` is mandatory** for non-doc tests (see Test stack above + `scripts/no-cargo-test.sh`). Hung test self-terminates via `.config/nextest.toml` slow-timeout; suite continues. Doctests still need `cargo test --doc`. +- Bash output silent >3min + `ps` shows 0% CPU with all threads on `futex_do_wait`/`ep_poll` = deadlock. Kill, `gdb -p -batch -ex "thread apply all bt 20"` for backtrace, fix root cause. +- Include this rule verbatim in every subagent dispatch that runs tests — they read CLAUDE.md but forget rules under load. +## Doc discipline (strict, enforced in CI) - Rust: `#![deny(rustdoc::broken_intra_doc_links)]` + `#![warn(missing_docs)]` workspace-wide. - Clippy: `-D warnings` + `clippy::cognitive_complexity`, @@ -73,12 +113,14 @@ adapters. `crates/{desktop,api,cli,ffi}` wire adapters to core. in a later phase. ## Schema rules (CRDT-ready from day one) - UUIDv7 PKs, `updated_at` + `device_id` on every mutable row, soft -deletes, no UNIQUE on mutable columns, no FK cascades. +deletes, no UNIQUE on mutable columns, no FK cascades. Every FTS / +search aggregation MUST filter `deleted_at IS NULL` on every joined +table (link + entity, e.g. both `file_tags` AND `tags`); every +trigger touching a soft-deletable table MUST handle both transition +directions (soft-delete AND restore). V007→V008 bug class. ## Git - - All work on `main`. No branches, no worktrees. - **Releases = semver tags** (`v0.N.x`). No fixed v1.0.0. "Phase" is internal vocabulary only — never in tags, commits, or CHANGELOG. @@ -91,6 +133,120 @@ deletes, no UNIQUE on mutable columns, no FK cascades. - New commits only; no amend, no `--no-verify`. ## Model defaults - -Opus 4.6 (1M) for planning/review; Sonnet 4.6 for bulk subagent execution; -Haiku 4.5 for trivial lookups. **Never include `Co-Authored-By:` in commits.** +Opus 4.6 for planning/review & complex coding. Sonnet for bulk/simple execution; Haiku 4.5 for trivial lookups. **Never include `Co-Authored-By:` in commits.** + +## Architecture conventions (evolving — see arch-audit umbrella spec) + +These sections are incrementally populated as Batches B-J land. Cross-ref: AGENTS.md for library/framework pins (different scope). + +### Crate topology (target post-Batch-B) + +- `crates/core` — domain types + trait ports, zero framework deps. +- `crates/app` — UseCase structs with `Arc` fields (Batch B). +- `crates/{db,fs,hash,media}` — adapters. +- `crates/{cli,desktop}` + future `api` + `ffi` — shell binaries, wire-up only. + +### Port traits + +- Repositories take `&self`, not `&mut self` (A2, landed `081c694` + `333c4b6`). +- Interior mutability lives in the adapter. +- No generic parameters on service structs; use `Arc` fields. + +### UseCase pattern (Batch B landed 2026-04-21) + +- Concrete `struct XxUseCase` with `Arc` fields; zero generics. +- Single `pub async fn execute(&self, cmd: XxCommand) -> Result`. +- One UseCase per user-visible workflow (not per entity). Current set: `Scan`, `Search`, `Tag`, `Volume`, `Metadata`. +- `AppContainer` is `#[derive(Clone)]`; fields are `Arc` + `Arc` (named `events`). +- `AppContainer::new(deps: AppDeps, handlers: Vec>)` is the ONLY `CompositeEventBus::new` construction site in production code. Adding a second is a regression. +- Shells build one container per process: desktop via Tauri `.setup(...)`, CLI via `build_container(db_path, extra_handlers)`. Shell-specific event handlers (Tauri emitter, db writeback) enter as `extra_handlers`. +- `LogEventHandler` lives in `perima_app::telemetry`. `DbEventHandler` stays shell-local in desktop (coupled to `SqliteFileRepository`). +- `CoreError::Internal(String)` wraps orchestration-layer errors until Batch D's typed `CoreError` discriminated union lands. +- Watch (filesystem watcher) is shell-local by design (`crates/cli/src/cmd/watch.rs` + `crates/desktop/src/commands.rs::{start,stop,is}_watch`); both consume `state.container.events` for emission. Tracked in #120. +- New write paths do NOT populate `hlc` in Batch B — deferred to Batch C's writer actor where the single SQL entry point can generate the HLC once per command. +- Post-Batch-B narrowing of shell-side residuals (volume filter arg, `TagOutput::Attached` widening, `detach by id`, `VolumeCommand::FindOrCreate`, `write_manifest` home) tracked on #119. + +### Connection model (Batch C landed 2026-04-22) + +- One `SqliteWriter` actor per process, on a `std::thread::spawn` OS thread. Owns the sole writable `rusqlite::Connection` for its lifetime. Writer is an OS thread, NOT a second tokio runtime — one-runtime-per-binary rule preserved. (Shells currently hold auxiliary writer handles for pre-container construction ordering — CLI 3 sites, desktop 2 sites; tracked in #125 for consolidation, not a blocker.) +- Writes: adapter methods build `WriteCmd::*` variants with `flume::bounded(1)` reply channels, `writer.send(cmd)` (sync), `reply_rx.recv()` (sync). Port traits stay sync; UseCase `execute` methods are async for forward-compat but hold no `.await` on the repo call. +- One HLC value per `WriteCmd` (== one user-visible logical event), stamped on every HLC-bearing row the command writes. Two WriteCmds in the same millisecond get distinct HLCs via the process-global counter in `HLC_STATE`. +- Reads: `r2d2::Pool`, size 4, `SQLITE_OPEN_READ_ONLY | SQLITE_OPEN_NO_MUTEX`, `with_init` pragmas (`busy_timeout=5000`, `temp_store=MEMORY`, `mmap_size=268435456`, `query_only=1`). +- Migrations: Refinery, run ONCE at startup inside `SqliteWriter::start`, synchronously, on the connection that becomes the writer. No per-command migration sniff. +- Domain events publish AFTER `COMMIT` from inside the writer. Handlers return `Result<(Out, Vec), CoreError>`; writer fans out via `Arc` passed from `AppContainer::new`. Rollback → zero events. Failed `emit()` logs via `tracing::warn!`, does not abort other handlers. Batch C handlers currently return empty event vecs — scaffolding in place for Batch E. +- Writer receives `Arc` from `AppContainer::new`. `crates/db` does NOT construct `CompositeEventBus`. Preserves Batch B's single-construction-site invariant. +- No `open_and_migrate` callsite in production code under `crates/{cli,desktop,app}/src/**` (non-`#[cfg(test)]`) outside `SqliteWriter::start`. The function itself stays in `crates/db/src/connection.rs` as the writer's migration primitive. +- `SqliteFileRepository` and `SqliteSearchRepository` (and the other three adapters) are `{ writer: Sender, reads: ReadPool }` only — no `Inner::Legacy` / `new_legacy`. Re-introducing that enum arm or constructor is a regression. +- Port traits in `crates/core/src/ports/*.rs` unchanged — sync `&self` methods, `Send + Sync` adapter bounds. +- **Why `r2d2_sqlite` over `deadpool-sqlite`:** port traits are sync; `deadpool-sqlite`'s async `interact` API would force async ports (out-of-batch refactor). Library-audit §Q1 deferred the pool pick; Batch C resolved to `r2d2_sqlite 0.32`. +- **Why `flume` for reply channels:** `tokio::sync::oneshot::Receiver::blocking_recv` panics inside a tokio runtime context; `flume::Receiver::recv` is runtime-agnostic. Single dep covers command channel + reply channels. +- FTS5 proptests (`fts_consistent_under_tag_churn`, `fts_matches_ground_truth_under_soft_delete_churn`) are capped at 64 / 32 cases respectively. Raising the cap without an FTS-oracle rewrite is a regression (#124). + +### IPC boundary contract (Batch D landed 2026-04-22) + +- Tauri commands return `Result`. NO handler returns `Result`. NO handler calls `.map_err(|e| e.to_string())`. +- `CoreError` lives in `crates/core::errors`. It derives `thiserror::Error + Serialize + Clone` always, and `specta::Type` behind the `specta` feature. `#[serde(tag = "kind", content = "data")]` produces a TypeScript discriminated union. +- `CoreError::Io { kind, message }` lowers `std::io::Error::kind()` (e.g. `"NotFound"`, `"PermissionDenied"`) to a string + the display message. Lossy by design — `std::io::Error` is `!Serialize`. Capture both fields, never just the message. Construct via `CoreError::from(io_err)` (single `From` impl in `crates/core/src/errors.rs`); `?`-propagation works through it. +- `crates/desktop` enables `perima-core/specta` and `perima-app/specta`. `crates/cli` does NOT — `crates/core` and `crates/app` stay framework-dep-free in CLI builds (verified: `cargo tree -i specta -p perima` is empty). +- `bindings.ts` is committed to git at `apps/desktop/src/bindings.ts`. It is generated by `tauri-specta` when `crates/desktop` is built with `--features specta-export`. CI runs `just bindings` (= `cargo build -p perima-desktop --features specta-export && git diff --exit-code apps/desktop/src/bindings.ts`) — drift fails the job. Local pre-push verification: `just bindings`. +- Production release builds do NOT enable `specta-export` (no surprise fs writes into the frontend source tree). +- `apps/desktop/src/types.ts` does NOT exist. Re-introducing it is a regression. All TS types come from `./bindings`. +- `apps/desktop/src/api.ts::fromInvoke` returns `ResultAsync` via `ResultAsync.fromPromise(invoke(...), parseCoreError)`. `parseCoreError` falls back to `{ kind: "Internal", data: }` for non-CoreError rejections. +- Re-introducing per-shell wire-type structs that mirror core types 1:1 is a regression — derive `specta::Type` on the core type instead. The exception is shell-private composite payloads that have no clean core analogue: `crates/desktop/src/payloads.rs` retains `FileWithMetadataPayload` (deliberate 17-field flat shape for grid binding) and `FileWithTagsPayload` (`#[serde(flatten)]` composition). Adding more shell-side composites is allowed only when the same 1:1-vs-composite decision is documented inline. +- The 10 specta-derived core types: `BlakeHash`, `FileSize`, `MediaPath`, `VolumeId`, `FileLocationRecord`, `VolumeRecord` (in `crates/core/src/types.rs`); `MediaMetadata`, `Tag`, `SearchHit`, `FileEvent` (one per file, same crate). `ScanReport` + `ScanReportEntry` from `crates/app/src/scan.rs` are also bindings-emitted (with `#[serde(skip)]` on shell-internal routing fields). Adding a new domain type that crosses the IPC boundary requires `#[cfg_attr(feature = "specta", derive(specta::Type))]` + `Serialize` on the type itself, NOT a wire-type mirror. +- `FileEvent` uses `#[serde(tag = "type")]` INLINE (no content key) to stay byte-compatible with the pre-Batch-D `FileEventPayload` channel contract; CoreError uses `#[serde(tag = "kind", content = "data")]`. Different shapes by design — CoreError is a Result error type, FileEvent is a v1-frozen channel payload. +- Adding a new `CoreError` variant: append to the enum with `#[error("…")]` + verify `bindings.ts` regenerates + extend frontend `parseCoreError`'s `KNOWN_KINDS` set + extend the `switch (err.kind)` block in `apps/desktop/src/components/StatusBar.tsx` (TypeScript exhaustiveness `never` default makes this a compile-error if missed). +- At least one frontend component (`StatusBar.tsx`) pattern-matches on `err.kind` with a TypeScript-exhaustive `never` default, proving the typed error flows end-to-end. Other components stringify via `apps/desktop/src/lib/coreError.ts::coreErrorMessage` until Batch H rewrites the toast/banner UX. + +### Event bus (Batch E landed 2026-04-23) + +- `EventBus` trait lives in `crates/core::events` as the publisher port. Single method: `fn emit(&self, event: &AppEvent) -> Result<(), CoreError>`. Sync — writer thread (Batch C) is not on a tokio runtime. +- `AppEvent` enum (in `crates/core::events`) has 3 variants: + - `File(FileEvent)` — wraps the existing filesystem-watcher event. + - `ScanCompleted { volume, files_seen, files_new, duration_ms }` — published by `ScanUseCase::execute` after a successful scan. + - `IndexInvalidated { reason: InvalidationReason }` — published by the writer actor after every `WriteCmd` that changes a query-relevant index. Reasons: `TagsChanged`, `FilesChanged`, `MetadataChanged`, `SearchIndexRebuilt`. +- `AppEvent` derives `Serialize + Clone` always; `specta::Type` behind the `specta` feature. `#[serde(tag = "kind", content = "data")]` produces the TypeScript discriminated union the frontend pattern-matches on. +- The concrete `Bus` lives in `crates/app::bus` and is the SOLE production impl of `EventBus`. Built once in `AppContainer::new`. Uses `async_broadcast::Sender` (capacity 256, backpressure mode = default — `set_overflow(false)`). Re-introducing `CompositeEventBus` is a regression. +- Handlers implement `EventHandler` (in `crates/app::events`) — `async fn handle(&mut self, event: AppEvent)`. Three production impls: `LogEventHandler` (`crates/app::telemetry`), `DbEventHandler` (shell-local: CLI's `crates/cli/src/cmd/watch.rs`, desktop's `crates/desktop/src/commands.rs`), `TauriEventHandler` (`crates/desktop/src/events.rs`). +- Handler tasks are spawned by `AppContainer::new` from a `Vec>` parameter. Each task owns its own `Receiver` and runs a `recv_loop` that handles `Overflowed` (lag → log warn + continue) and `Closed` (shutdown → exit). Panics inside `handle` are caught + logged; the loop continues. +- `Bus::emit` is sync `try_broadcast`. On `Full` (slow subscriber): `tracing::warn!` + return Ok — the writer is best-effort. Capacity 256 + fast-handler invariant means Full should not fire under normal load. +- `tokio::sync::broadcast` is BANNED. It silently drops on lag. Use `async_broadcast::broadcast` (default = backpressure mode). +- Tauri channel: ONE channel `"app-event"` carries `AppEvent`. The previous `"file-event"` channel is gone. Frontend `apps/desktop/src/api.ts::subscribeToAppEvents` is the single subscription point. +- The writer's per-`WriteCmd` handler returns `Result<(Out, Vec), CoreError>`. Empty Vec = no event. Each `IndexInvalidated::*` reason maps to a category of writes per the spec — adding a new write type that affects a query requires populating the Vec. +- `NoopBus` (`crates/db::test_utils::noop_bus`) is the test stub. Used by writer tests that don't care about events. `MockEventBus` (in `crates/fs/src/watcher.rs::tests`) collects events for assertion. Both impl the `EventBus` trait. +- Adding a new `AppEvent` variant: append to enum + add `#[serde(...)]` tag if needed + verify `bindings.ts` regenerates + extend frontend `App.tsx` switch on `event.kind` (no exhaustiveness check today — a future Batch H refactor may add one). +- Adding a new `EventHandler`: impl the trait + add to the shell's handler `Vec` passed into `AppContainer::new`. + +### Frontend state (Batch H will fill in) + +Stub: three-layer split. TanStack Query v5 for server-state via `bindings.ts` commands. Zustand 5 for global UI state. `useState` for component-local only. TanStack Router with `createHashHistory`. Cache invalidation driven by `AppEvent` listener, never by Rust-side `invalidate_query!`-style macros. React Compiler 1.0 is on (L2 landed); do not write manual `useMemo`/`useCallback`. Details in Batch H spec. + +### Observability (Batch I will fill in) + +Stub: `tracing` + `tracing-subscriber` JSON in release; `#[tracing::instrument]` on every `UseCase::execute`; `perima --debug-report` dumps last N traced events. Details in Batch I spec. + +### Tokio runtime policy + +- One tokio runtime per binary. Never spawn a second. +- Desktop: Tauri owns the runtime. +- CLI: `#[tokio::main]`. +- Tests: `#[tokio::test]` or single-threaded runtime per test. +- Writer actor (Batch C) is a dedicated OS thread, NOT a second runtime. + +### Error handling + +- `thiserror` in lib crates (`crates/core`, `db`, `fs`, `hash`, `media`, future `app`). +- `miette` with `fancy` feature in binary crates ONLY (`crates/cli`, `crates/desktop`) — landed L7 (`938eccc`). +- `CoreError` lives in `crates/core::errors`. It does NOT derive `miette::Diagnostic` — binary-UX-only concern that stays at the binary boundary. Batch D will add `#[derive(Serialize, specta::Type)]` + `#[serde(tag = "kind", content = "data")]` behind the `specta` feature. +- `anyhow` is rejected in `crates/cli` + `crates/desktop` declared deps (L7 removed it); transitive via deps is acceptable. +- `color-eyre` is rejected (archived upstream). + +### Schema rules expansion (HLC + shared_doc — Batch A) + +- Every mutable **user-editable + sync-eligible** row carries an `hlc INTEGER` column alongside `updated_at` (v1 posture: nullable, populated in Batch B+). +- Device-local rows (`volume_mounts`, `scan_progress`, `thumbnail_queue`, `device_config`) do NOT carry `hlc` — their identity is machine-scoped and they never sync. +- `shared_doc` table is reserved empty for post-v1 Loro integration. Do NOT add rows in v1. Do NOT add `loro` crate. +- HLC packing: 48 low-bits ms + 16 high-bits counter → non-negative i64. `crates/core::Hlc` provides `now()` + `pack()` + `unpack()`. +- Existing rules (unchanged): UUIDv7 PKs, `updated_at` + `device_id` on every mutable row, soft deletes, no UNIQUE on mutable columns, no FK cascades, FTS filters + triggers respect soft-delete + restore (V007→V008 bug class). + +Note: for now, dont commit plans, specs, claude.md updates until the human says otherwise. diff --git a/Cargo.lock b/Cargo.lock index 1716a82..b011804 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -189,6 +189,18 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -578,6 +590,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -654,6 +677,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.16.3" @@ -1168,6 +1200,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "exr" version = "1.74.0" @@ -1223,6 +1276,9 @@ name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +dependencies = [ + "getrandom 0.3.4", +] [[package]] name = "fax" @@ -1288,6 +1344,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "fastrand", + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1361,6 +1429,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1368,6 +1451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1422,6 +1506,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1590,9 +1675,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1604,6 +1691,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -2517,9 +2605,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", @@ -3216,6 +3304,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3261,6 +3355,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" name = "perima" version = "0.6.4" dependencies = [ + "async-trait", "chrono", "clap", "ctrlc", @@ -3269,6 +3364,7 @@ dependencies = [ "insta", "miette", "nix", + "perima-app", "perima-core", "perima-db", "perima-fs", @@ -3286,6 +3382,32 @@ dependencies = [ "uuid", ] +[[package]] +name = "perima-app" +version = "0.6.4" +dependencies = [ + "async-broadcast", + "async-trait", + "futures", + "insta", + "perima-core", + "perima-db", + "perima-fs", + "perima-hash", + "perima-media", + "proptest", + "rayon", + "rusqlite", + "serde", + "specta", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + [[package]] name = "perima-core" version = "0.6.4" @@ -3293,6 +3415,8 @@ dependencies = [ "blake3", "proptest", "serde", + "serde_json", + "specta", "tempfile", "thiserror 2.0.18", "unicode-normalization", @@ -3305,8 +3429,12 @@ version = "0.6.4" dependencies = [ "blake3", "chrono", + "flume", "perima-core", + "perima-db", "proptest", + "r2d2", + "r2d2_sqlite", "refinery", "rusqlite", "tempfile", @@ -3319,10 +3447,12 @@ dependencies = [ name = "perima-desktop" version = "0.6.4" dependencies = [ + "async-trait", "chrono", "directories", "image", "miette", + "perima-app", "perima-core", "perima-db", "perima-fs", @@ -3835,6 +3965,28 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "r2d2_sqlite" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5576df16239e4e422c4835c8ed00be806d4491855c7847dba60b7aa8408b469b" +dependencies = [ + "r2d2", + "rusqlite", + "uuid", +] + [[package]] name = "rand" version = "0.7.3" @@ -3870,6 +4022,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -3927,6 +4090,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_hc" version = "0.2.0" @@ -4072,9 +4241,8 @@ dependencies = [ [[package]] name = "refinery" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee5133e5b207e5703c2a4a9dc9bd8c8f2cc74c4ac04ca5510acaa907012c77ac" +version = "0.9.0" +source = "git+https://github.com/tomasol/refinery?branch=pr-rusqlite-39#21d98a862cfdcd08bd03481e5ef97d8ae0a9563e" dependencies = [ "refinery-core", "refinery-macros", @@ -4082,9 +4250,8 @@ dependencies = [ [[package]] name = "refinery-core" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "023a2a96d959c9b5b5da78e965bfdb1363b365bf5e84531a67d0eee827a702a3" +version = "0.9.0" +source = "git+https://github.com/tomasol/refinery?branch=pr-rusqlite-39#21d98a862cfdcd08bd03481e5ef97d8ae0a9563e" dependencies = [ "async-trait", "cfg-if", @@ -4102,9 +4269,8 @@ dependencies = [ [[package]] name = "refinery-macros" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56c2e960c8e47c7c5c30ad334afea8b5502da796a59e34d640d6239d876d924" +version = "0.9.0" +source = "git+https://github.com/tomasol/refinery?branch=pr-rusqlite-39#21d98a862cfdcd08bd03481e5ef97d8ae0a9563e" dependencies = [ "proc-macro2", "quote", @@ -4218,9 +4384,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ "bitflags 2.11.1", "fallible-iterator", @@ -4292,6 +4458,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + [[package]] name = "schemars" version = "0.8.22" @@ -4711,6 +4886,7 @@ dependencies = [ "paste", "rustc_version", "specta-macros", + "uuid", ] [[package]] @@ -4755,6 +4931,15 @@ dependencies = [ "specta", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.3" @@ -5942,6 +6127,7 @@ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", + "rand 0.10.1", "serde_core", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 79c589f..57a6a3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,10 +42,17 @@ print_stdout = "warn" print_stderr = "warn" [workspace.dependencies] +# WHY workspace-path dep for perima-app: release-plz + downstream shells +# (cli, desktop) need it reachable via `workspace = true` so bumps +# propagate correctly via the workspace graph. +perima-app = { path = "crates/app", version = "0.6.4" } # WHY workspace-path dep for perima-media: release-plz + other crates # (perima CLI) need it reachable via `workspace = true` so bumps # propagate correctly via the workspace graph. perima-media = { path = "crates/media", version = "0.6.3" } +async-broadcast = "=0.7.2" +async-trait = "=0.1.89" +futures = "0.3" anyhow = "1" miette = { version = "7", features = ["fancy"] } thiserror = "2" @@ -57,8 +64,11 @@ tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } uuid = { version = "1", features = ["v4", "v7", "serde"] } blake3 = "1" -rusqlite = { version = "0.38", features = ["bundled"] } +rusqlite = { version = "0.39", features = ["bundled"] } refinery = { version = "0.9", features = ["rusqlite"] } +flume = "0.12" +r2d2 = "0.8" +r2d2_sqlite = "0.33" walkdir = "2" notify = "8.2" notify-debouncer-full = "0.7" @@ -92,3 +102,17 @@ mime_guess = "2" [profile.release] lto = "thin" codegen-units = 1 + +# WHY [patch.crates-io] for refinery: rusqlite 0.39 (which bundles SQLite +# 3.51.3, fixing the AB-BA lock-order inversion in unixClose vs unixLock +# documented in GH #131) requires refinery-core to widen its `rusqlite` +# version cap from `<=0.38` to `<=0.39`. The upstream PR +# https://github.com/rust-db/refinery/pull/425 ("chore: Support rusqlite +# 0.39.x") is mergeable + all CI checks green, awaiting maintainer merge. +# Pinning to the PR branch via this patch resolves the libsqlite3-sys +# `links = "sqlite3"` conflict and lets the SQLite version bump land. +# Removal: drop both lines once refinery 0.10 (or whatever ships #425) +# is on crates.io and the workspace dep is bumped to it. +[patch.crates-io] +refinery = { git = "https://github.com/tomasol/refinery", branch = "pr-rusqlite-39" } +refinery-core = { git = "https://github.com/tomasol/refinery", branch = "pr-rusqlite-39" } diff --git a/_typos.toml b/_typos.toml index 97d7a7d..9626ad4 100644 --- a/_typos.toml +++ b/_typos.toml @@ -12,6 +12,11 @@ extend-exclude = [ "dist/", "*.snap", "CHANGELOG.md", # existing past-tense entries; don't retro-edit + # WHY: superpowers specs/plans/cheatsheet contain many short git SHAs + # (e.g. `8ba3f04`, `6f74e30`) whose hex fragments trip the typos dictionary + # as false positives. These files are authored-for-AI-sessions drafts; + # typos noise here burns more minutes than it saves. + "docs/superpowers/**", ] [default.extend-words] diff --git a/apps/desktop/eslint.config.js b/apps/desktop/eslint.config.js index c8a6a59..04d023e 100644 --- a/apps/desktop/eslint.config.js +++ b/apps/desktop/eslint.config.js @@ -40,7 +40,7 @@ export default [ document: "readonly", console: "readonly", // WHY: WebView environment exposes WHATWG timers; we use them for - // the 300ms file-event debounce in App.tsx. + // the 300ms app-event debounce in App.tsx. setTimeout: "readonly", clearTimeout: "readonly", // WHY: TextEncoder is a WHATWG Encoding API global available in diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index ff97bb3..28d6a43 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -9,7 +9,8 @@ import StatusBar from "./components/StatusBar"; import TagSidebar from "./components/TagSidebar"; import WatcherBanner from "./components/WatcherBanner"; import { composeVisible, computeFacets, sortByRank } from "./lib/search"; -import type { FileWithTags, ScanResult, SearchHit, Tag } from "./types"; +import { coreErrorMessage } from "./lib/coreError"; +import type { AppEvent, CoreError, FileWithTagsPayload, ScanReport, SearchHit, Tag } from "./bindings"; /** * Which rendering mode the main file list uses. @@ -29,15 +30,15 @@ type ViewMode = "table" | "grid"; * consumers grows beyond 2–3 components. */ export default function App() { - const [files, setFiles] = useState([]); + const [files, setFiles] = useState([]); const [tags, setTags] = useState([]); // WHY string | null (not Set): spec models multi-select as Set // but v0.5.1 ships single-select only. Using null for "All" is simpler and // avoids converting Set → serializable state. Upgrade to Set when multi-select lands. const [selectedTagId, setSelectedTagId] = useState(null); - const [scanResult, setScanResult] = useState(null); + const [scanResult, setScanResult] = useState(null); const [scanning, setScanning] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [loading, setLoading] = useState(true); // WHY: Watcher failures are non-blocking (the table is still accurate, // just not live-updating). Surface them via a dismissible banner rather @@ -88,26 +89,56 @@ export default function App() { // or the debounced refresh resolves post-cleanup. let active = true; + /** Shared refetch helper — called by multiple AppEvent branches. */ + const refetch = () => { + // WHY no listTags() here: the file watcher fires on filesystem + // events (file created/deleted/modified). Tags are only mutated + // via explicit Tauri commands from within this app — no external + // process can change file_tags without going through Tauri. A + // tag re-fetch on every file event would be wasteful and + // incorrect; tags refresh after scan (handleScanComplete) where + // new tags may actually have been created. + void api.listFilesWithTags(100).match( + (refreshed) => { + if (active) setFiles(refreshed); + }, + (err) => { + if (active) setError(err); + }, + ); + }; + api - .subscribeToFileEvents(() => { - if (timer) clearTimeout(timer); - timer = setTimeout(() => { - // WHY no listTags() here: the file watcher fires on filesystem - // events (file created/deleted/modified). Tags are only mutated - // via explicit Tauri commands from within this app — no external - // process can change file_tags without going through Tauri. A - // tag re-fetch on every file event would be wasteful and - // incorrect; tags refresh after scan (handleScanComplete) where - // new tags may actually have been created. - void api.listFilesWithTags(100).match( - (refreshed) => { - if (active) setFiles(refreshed); - }, - (err) => { - if (active) setError(err); - }, - ); - }, 300); + .subscribeToAppEvents((event: AppEvent) => { + switch (event.kind) { + case "File": + // WHY 300ms debounce: a watcher burst (e.g., file-copy of 100 + // files) shouldn't trigger 100 list_files_with_tags refetches. + if (timer) clearTimeout(timer); + timer = setTimeout(refetch, 300); + break; + case "ScanCompleted": + // WHY immediate (no debounce): scan-end is rare + intentional; + // the user is waiting for their scanned files to appear. + if (timer) clearTimeout(timer); + refetch(); + break; + case "IndexInvalidated": + // TODO Batch H: split per event.data.reason (TagsChanged / FilesChanged + // / MetadataChanged / SearchIndexRebuilt) for surgical TanStack + // invalidation. Currently coarse → debounced refetch matches + // the File-event behavior. + if (timer) clearTimeout(timer); + timer = setTimeout(refetch, 300); + break; + default: { + // WHY exhaustiveness check: ensures the switch stays complete + // as new AppEvent variants are added (matches StatusBar.tsx + // pattern from Batch D). + const _exhaustive: never = event; + throw new Error(`Unhandled AppEvent kind: ${JSON.stringify(_exhaustive)}`); + } + } }) .then((fn) => { if (active) { @@ -136,7 +167,7 @@ export default function App() { setError(null); } - function handleScanComplete(result: ScanResult, path: string) { + function handleScanComplete(result: ScanReport, path: string) { setScanResult(result); setScanning(false); // Refresh file list and tags after a successful scan. @@ -156,7 +187,9 @@ export default function App() { // complete. void api.startWatch(path).match( () => { setWatcherError(null); }, - (err) => { setWatcherError(`Failed to start watcher: ${err}`); }, + (err) => { + setWatcherError(`Failed to start watcher [${err.kind}]: ${coreErrorMessage(err)}`); + }, ); } diff --git a/apps/desktop/src/__tests__/App.test.tsx b/apps/desktop/src/__tests__/App.test.tsx index e3d6162..52b21aa 100644 --- a/apps/desktop/src/__tests__/App.test.tsx +++ b/apps/desktop/src/__tests__/App.test.tsx @@ -5,14 +5,14 @@ import { invoke } from "@tauri-apps/api/core"; import type { Mock } from "vitest"; import App from "../App"; -describe("App file-event debounce", () => { +describe("App app-event handling", () => { beforeEach(() => { vi.useFakeTimers(); (invoke as Mock).mockReset(); (listen as Mock).mockReset(); }); - test("5 rapid file-events within 300ms trigger at most 1 list_files call", async () => { + test("5 rapid File-events within 300ms trigger at most 1 list_files_with_tags call", async () => { // Initial mount: both list_files_with_tags and list_tags resolve to []. (invoke as Mock).mockImplementation((cmd: string) => { if (cmd === "list_tags") return Promise.resolve([]); @@ -30,19 +30,19 @@ describe("App file-event debounce", () => { ); // WHY act() around mount: mount effects schedule async list_files_with_tags - // and list_tags and the subscribeToFileEvents promise, all of which land + // and list_tags and the subscribeToAppEvents promise, all of which land // state updates. await act(async () => { render(); // WHY Promise.resolve chains: flush microtasks from mount effects - // (list_files_with_tags, list_tags, subscribeToFileEvents). + // (list_files_with_tags, list_tags, subscribeToAppEvents). await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); // Ignore the initial list_files_with_tags call from mount — only count - // events fired after we start dispatching file-events. + // events fired after we start dispatching app-events. (invoke as Mock).mockClear(); (invoke as Mock).mockImplementation((cmd: string) => { if (cmd === "list_tags") return Promise.resolve([]); @@ -67,9 +67,12 @@ describe("App file-event debounce", () => { for (let i = 0; i < 5; i++) { capturedHandler!({ payload: { - type: "Created", - path: `file${i}.txt`, - volume: "00000000-0000-0000-0000-000000000000", + kind: "File", + data: { + type: "Created", + path: `file${i}.txt`, + volume: "00000000-0000-0000-0000-000000000000", + }, }, }); } @@ -98,7 +101,71 @@ describe("App file-event debounce", () => { expect(postCalls).toHaveLength(1); }); - test("surfaces watcher banner when subscribeToFileEvents fails", async () => { + test("ScanCompleted triggers immediate refetch (no debounce)", async () => { + (invoke as Mock).mockImplementation((cmd: string) => { + if (cmd === "list_tags") return Promise.resolve([]); + if (cmd === "list_files_with_tags") return Promise.resolve([]); + return Promise.resolve([]); + }); + + let capturedHandler: ((ev: { payload: unknown }) => void) | null = null; + (listen as Mock).mockImplementation( + (_event: unknown, handler: (ev: { payload: unknown }) => void) => { + capturedHandler = handler; + return Promise.resolve(() => { /* noop unsubscribe */ }); + }, + ); + + await act(async () => { + render(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // Clear mount calls; only track post-mount invocations. + (invoke as Mock).mockClear(); + (invoke as Mock).mockImplementation((cmd: string) => { + if (cmd === "list_tags") return Promise.resolve([]); + if (cmd === "list_files_with_tags") return Promise.resolve([]); + return Promise.resolve([]); + }); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!capturedHandler) { + throw new Error("listen handler was never captured"); + } + + // Fire a ScanCompleted event — should trigger an immediate refetch, + // no timer needed. + await act(async () => { + capturedHandler!({ + payload: { + kind: "ScanCompleted", + data: { + volume: "00000000-0000-0000-0000-000000000000", + files_seen: 10, + files_new: 3, + duration_ms: 1234, + }, + }, + }); + // WHY two microtask flushes: refetch() calls api.listFilesWithTags + // which returns a ResultAsync (Promise). The first resolve tick + // queues the invoke; the second lets the mock resolve and the + // .match() callback run. + await Promise.resolve(); + await Promise.resolve(); + }); + + // Should refetch IMMEDIATELY without waiting for the 300ms debounce. + const calls = (invoke as Mock).mock.calls.filter( + ([cmd]) => cmd === "list_files_with_tags", + ); + expect(calls.length).toBeGreaterThanOrEqual(1); + }); + + test("surfaces watcher banner when subscribeToAppEvents fails", async () => { (invoke as Mock).mockImplementation((cmd: string) => { if (cmd === "list_tags") return Promise.resolve([]); if (cmd === "list_files_with_tags") return Promise.resolve([]); diff --git a/apps/desktop/src/__tests__/FileGrid.test.tsx b/apps/desktop/src/__tests__/FileGrid.test.tsx index 68acf86..9b29cb6 100644 --- a/apps/desktop/src/__tests__/FileGrid.test.tsx +++ b/apps/desktop/src/__tests__/FileGrid.test.tsx @@ -1,14 +1,14 @@ import { render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; import FileGrid from "../components/FileGrid"; -import type { FileWithTags } from "../types"; +import type { FileWithTagsPayload } from "../bindings"; /** - * Build a {@link FileWithTags} with every non-relevant field zeroed + * Build a {@link FileWithTagsPayload} with every non-relevant field zeroed * out. Only the caller-supplied overrides (hash + thumbnail state) matter * for these assertions. */ -function makeFile(overrides: Partial): FileWithTags { +function makeFile(overrides: Partial): FileWithTagsPayload { return { hash: "0".repeat(64), size: 1024, @@ -34,7 +34,7 @@ function makeFile(overrides: Partial): FileWithTags { describe("FileGrid", () => { it("renders an for ready tiles and placeholders for others", () => { - const files: FileWithTags[] = [ + const files: FileWithTagsPayload[] = [ makeFile({ hash: "a".repeat(64), relative_path: "photos/ready.jpg", diff --git a/apps/desktop/src/__tests__/FileTable.test.tsx b/apps/desktop/src/__tests__/FileTable.test.tsx index 1e20c43..5090727 100644 --- a/apps/desktop/src/__tests__/FileTable.test.tsx +++ b/apps/desktop/src/__tests__/FileTable.test.tsx @@ -1,9 +1,9 @@ import { render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; import FileTable from "../components/FileTable"; -import type { FileWithTags } from "../types"; +import type { FileWithTagsPayload } from "../bindings"; -const makeEntry = (n: number): FileWithTags => ({ +const makeEntry = (n: number): FileWithTagsPayload => ({ hash: "a".repeat(62) + String(n).padStart(2, "0"), size: 1024 * n, volume_id: "00000000-0000-0000-0000-00000000000" + n, @@ -25,7 +25,7 @@ const makeEntry = (n: number): FileWithTags => ({ }); describe("FileTable", () => { - it("renders a row for each FileWithTags", () => { + it("renders a row for each FileWithTagsPayload", () => { const files = [makeEntry(1), makeEntry(2), makeEntry(3)]; render(); diff --git a/apps/desktop/src/__tests__/ScanButton.test.tsx b/apps/desktop/src/__tests__/ScanButton.test.tsx index bcf6086..37a8f05 100644 --- a/apps/desktop/src/__tests__/ScanButton.test.tsx +++ b/apps/desktop/src/__tests__/ScanButton.test.tsx @@ -2,7 +2,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { okAsync } from "neverthrow"; import ScanButton from "../components/ScanButton"; -import type { ScanResult } from "../types"; +import type { ScanReport } from "../bindings"; // WHY: api module calls invoke internally; mock it at the module level so // tests never touch the real Tauri runtime. @@ -17,7 +17,16 @@ import * as api from "../api"; const mockOpen = vi.mocked(dialogOpen); const mockScan = vi.mocked(api.scan); -const mockResult: ScanResult = { total: 10, new: 3, existing: 7, errors: 0 }; +const mockResult: ScanReport = { + files_seen: 10, + files_new: 3, + files_updated: 7, + files_errored: 0, + bytes_hashed: 1024, + duration_ms: 42, + interrupted: false, + volume_label: null, +}; beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/desktop/src/__tests__/SearchBar.test.tsx b/apps/desktop/src/__tests__/SearchBar.test.tsx index c476c01..3e8f851 100644 --- a/apps/desktop/src/__tests__/SearchBar.test.tsx +++ b/apps/desktop/src/__tests__/SearchBar.test.tsx @@ -2,7 +2,7 @@ import { render, screen, fireEvent, act } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { okAsync, errAsync } from "neverthrow"; import SearchBar from "../components/SearchBar"; -import type { SearchHit } from "../types"; +import type { CoreError, SearchHit } from "../bindings"; vi.mock("../api", () => ({ search: vi.fn(), @@ -120,7 +120,7 @@ describe("SearchBar", () => { }); it("swallows backend errors and fires onQueryChange(raw, [])", async () => { - mockSearch.mockReturnValue(errAsync("FTS5 parse error")); + mockSearch.mockReturnValue(errAsync({ kind: "Internal", data: "FTS5 parse error" })); const onChange = vi.fn(); render(); diff --git a/apps/desktop/src/__tests__/StatusBar.test.tsx b/apps/desktop/src/__tests__/StatusBar.test.tsx index 84c7467..4faf13d 100644 --- a/apps/desktop/src/__tests__/StatusBar.test.tsx +++ b/apps/desktop/src/__tests__/StatusBar.test.tsx @@ -1,20 +1,30 @@ import { render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; import StatusBar from "../components/StatusBar"; -import type { ScanResult } from "../types"; +import type { ScanReport, CoreError } from "../bindings"; -const mockResult: ScanResult = { total: 42, new: 5, existing: 37, errors: 0 }; +const mockResult: ScanReport = { + files_seen: 42, + files_new: 5, + files_updated: 37, + files_errored: 0, + bytes_hashed: 4096, + duration_ms: 100, + interrupted: false, + volume_label: null, +}; describe("StatusBar", () => { it("shows scan summary when scanResult is present", () => { render(); expect(screen.getByText(/scanned 42 files/i)).toBeInTheDocument(); expect(screen.getByText(/5 new/i)).toBeInTheDocument(); - expect(screen.getByText(/37 existing/i)).toBeInTheDocument(); + expect(screen.getByText(/37 updated/i)).toBeInTheDocument(); }); it("shows error string when error is present", () => { - render(); + const err: CoreError = { kind: "Internal", data: "disk read failure" }; + render(); const errEl = screen.getByText(/disk read failure/i); expect(errEl).toBeInTheDocument(); // WHY: error text must be visually distinct (red) — check for a red class. @@ -25,4 +35,21 @@ describe("StatusBar", () => { render(); expect(screen.getByText("No scans yet")).toBeInTheDocument(); }); + + // WHY: These two tests pin the switch(err.kind) discriminated-union branches + // added in Task 11 — NotFound renders distinct UX; all other variants fall + // through to the generic catch-all path. + + it("shows 'No results found.' for NotFound errors (distinct UX branch)", () => { + const err: CoreError = { kind: "NotFound", data: "query returned nothing" }; + render(); + expect(screen.getByText("No results found.")).toBeInTheDocument(); + }); + + it("shows generic error message for Internal errors (catch-all branch)", () => { + const err: CoreError = { kind: "Internal", data: "unexpected db error" }; + render(); + expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); + expect(screen.getByText(/unexpected db error/i)).toBeInTheDocument(); + }); }); diff --git a/apps/desktop/src/__tests__/api.test.ts b/apps/desktop/src/__tests__/api.test.ts new file mode 100644 index 0000000..62422ec --- /dev/null +++ b/apps/desktop/src/__tests__/api.test.ts @@ -0,0 +1,82 @@ +/** + * Unit tests for `parseCoreError` and the `fromInvoke` round-trip. + * + * WHY: `parseCoreError` is the only non-trivial logic in api.ts. + * These tests pin the three cases guaranteed by spec §4.3: + * 1. Typed rejection → pass-through (NotFound, Io, etc.) + * 2. Nested struct variant → preserved (Io with kind+message object) + * 3. Unrecognised rejection → fallback Internal + */ +import { describe, it, expect } from "vitest"; +import { parseCoreError } from "../api"; +import type { CoreError } from "../bindings"; + +// ── parseCoreError unit tests ───────────────────────────────────────── + +describe("parseCoreError", () => { + it("passes through a typed NotFound rejection unchanged", () => { + const raw: CoreError = { kind: "NotFound", data: "file not found" }; + const result = parseCoreError(raw); + expect(result).toEqual({ kind: "NotFound", data: "file not found" }); + }); + + it("passes through a typed Io rejection with nested kind+message", () => { + const raw: CoreError = { + kind: "Io", + data: { kind: "PermissionDenied", message: "permission denied (os error 13)" }, + }; + const result = parseCoreError(raw); + expect(result).toEqual({ + kind: "Io", + data: { kind: "PermissionDenied", message: "permission denied (os error 13)" }, + }); + }); + + it("falls back to Internal for an unrecognised rejection shape", () => { + // Simulates a Tauri runtime error (command not registered, IPC failure, etc.) + const raw = "command not registered"; + const result = parseCoreError(raw); + expect(result).toEqual({ + kind: "Internal", + data: "command not registered", + }); + }); + + it("falls back to Internal for an object with unknown kind", () => { + const raw = { kind: "NonExistentVariant", data: "whatever" }; + const result = parseCoreError(raw); + expect(result).toEqual({ + kind: "Internal", + data: JSON.stringify(raw), + }); + }); + + it("falls back to Internal for null rejection", () => { + const result = parseCoreError(null); + expect(result).toEqual({ + kind: "Internal", + data: "null", + }); + }); + + it("passes through all 8 known variant kinds (7 string-data + 1 struct-data Io)", () => { + const kinds: Array = [ + "NotFound", + "Duplicate", + "InvalidPath", + "InvalidHash", + "InvalidTag", + "Unsupported", + "Internal", + ]; + for (const kind of kinds) { + const raw = { kind, data: "test" }; + const result = parseCoreError(raw); + expect(result.kind).toBe(kind); + } + // Io has a struct data field — test separately + const ioRaw = { kind: "Io", data: { kind: "NotFound", message: "file missing" } }; + const ioResult = parseCoreError(ioRaw); + expect(ioResult.kind).toBe("Io"); + }); +}); diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index b0e152c..a61df3b 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -2,36 +2,89 @@ * Thin `neverthrow` wrappers around Tauri IPC commands. * * WHY: Components use `.match()` instead of try/catch, making error paths - * explicit and type-checked. `ResultAsync` is the contract. + * explicit and type-checked. `ResultAsync` is the contract. + * WHY CoreError not string: the backend now returns a typed discriminated + * union `{ kind, data }` so the frontend can branch on recoverable vs not + * (e.g. "NotFound" → soft refresh, "Io.kind=PermissionDenied" → modal). */ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { ResultAsync } from "neverthrow"; import type { - FileEntry, - FileEvent, - FileWithMetadata, - FileWithTags, - ScanResult, + AppEvent, + CoreError, + FileLocationRecord, + FileWithMetadataPayload, + FileWithTagsPayload, + ScanReport, SearchHit, Tag, - VolumeEntry, -} from "./types"; + VolumeRecord, +} from "./bindings"; + +// ── Error parsing ───────────────────────────────────────────────────── /** - * Wraps a Tauri `invoke` call in a `ResultAsync`, mapping thrown errors to - * their string message. + * All discriminant strings recognised in `CoreError`. + * Kept in sync with `crates/core/src/errors.rs` variants. + * WHY a Set: `KNOWN_KINDS.has(x)` is O(1) and type-narrows cleanly. + */ +const KNOWN_KINDS: ReadonlySet = new Set([ + "NotFound", + "Duplicate", + "InvalidPath", + "InvalidHash", + "InvalidTag", + "Io", + "Unsupported", + "Internal", +]); + +/** + * Convert an unknown Tauri rejection value into a `CoreError`. + * + * Tauri rejects with the JSON-serialised `Result::Err` payload when a + * command handler returns `Err(CoreError::…)`. The payload is already + * `{ kind: "…", data: … }` on the wire, so we just validate and + * pass it through. + * + * The fallback path covers: + * - Tauri runtime errors that reject before the command runs (e.g. + * command-not-registered, IPC serialisation failures). + * - Future `CoreError` variants the frontend hasn't picked up yet + * (graceful degradation rather than crash). + */ +export function parseCoreError(raw: unknown): CoreError { + if (typeof raw === "object" && raw !== null && "kind" in raw) { + const r = raw as { kind: unknown; data?: unknown }; + if ( + typeof r.kind === "string" && + KNOWN_KINDS.has(r.kind as CoreError["kind"]) + ) { + return r as CoreError; + } + } + return { + kind: "Internal", + data: typeof raw === "string" ? raw : JSON.stringify(raw), + }; +} + +// ── IPC wrapper ─────────────────────────────────────────────────────── + +/** + * Wraps a Tauri `invoke` call in a `ResultAsync`, mapping rejected + * values to typed `CoreError` via `parseCoreError`. */ function fromInvoke( cmd: string, args: Record, -): ResultAsync { - return ResultAsync.fromPromise( - invoke(cmd, args), - (e) => (e instanceof Error ? e.message : String(e)), - ); +): ResultAsync { + return ResultAsync.fromPromise(invoke(cmd, args), parseCoreError); } +// ── Command wrappers ────────────────────────────────────────────────── + /** * Scan a directory tree, hashing and indexing every file found. * @@ -41,7 +94,7 @@ function fromInvoke( export function scan( path: string, dryRun: boolean, -): ResultAsync { +): ResultAsync { // WHY: Tauri v2 auto-converts camelCase args to snake_case on the Rust side. return fromInvoke("scan", { path, dryRun }); } @@ -55,7 +108,7 @@ export function scan( export function listFiles( limit: number, volume?: string, -): ResultAsync { +): ResultAsync { return fromInvoke("list_files", { limit, volume: volume ?? null }); } @@ -73,7 +126,7 @@ export function listFiles( export function listFilesWithMetadata( limit: number, volume?: string, -): ResultAsync { +): ResultAsync { return fromInvoke("list_files_with_metadata", { limit, volume: volume ?? null, @@ -83,7 +136,7 @@ export function listFilesWithMetadata( /** * List all volumes known to the database. */ -export function listVolumes(): ResultAsync { +export function listVolumes(): ResultAsync { return fromInvoke("list_volumes", {}); } @@ -91,45 +144,49 @@ export function listVolumes(): ResultAsync { * Start watching the given folder for filesystem changes. * * Cancels any currently active watcher. Events are emitted via the - * Tauri `file-event` channel; subscribe with {@link subscribeToFileEvents}. + * Tauri `app-event` channel; subscribe with {@link subscribeToAppEvents}. */ -export function startWatch(path: string): ResultAsync { +export function startWatch(path: string): ResultAsync { return fromInvoke("start_watch", { path }); } /** Stop the active watcher, if any. No-op when nothing is watched. */ -export function stopWatch(): ResultAsync { +export function stopWatch(): ResultAsync { return fromInvoke("stop_watch", {}); } /** Query whether a watcher is currently active. */ -export function isWatching(): ResultAsync { +export function isWatching(): ResultAsync { return fromInvoke("is_watching", {}); } -/** Returned by {@link subscribeToFileEvents}; call to stop listening. */ +/** Returned by {@link subscribeToAppEvents}; call to stop listening. */ export type UnsubscribeFn = () => void; /** - * Subscribe to `file-event` notifications emitted by the backend watcher. + * Subscribe to `app-event` notifications emitted by the backend bus. * * Resolves to an unsubscribe function. Consumers MUST call it on cleanup - * to avoid leaks (e.g., from `useEffect` return). + * to avoid leaked listeners (e.g., from `useEffect` return). + * + * Channel renamed from `"file-event"` to `"app-event"` in Batch E — the + * single channel now carries the full `AppEvent` envelope (`File`, + * `ScanCompleted`, `IndexInvalidated`). * * WHY wrap `listen`: the raw `@tauri-apps/api/event` listener passes a * `{ payload, event, id, ... }` object to the callback; we unwrap the - * payload so consumers only deal with the typed `FileEvent`. + * payload so consumers only deal with the typed `AppEvent`. */ -export async function subscribeToFileEvents( - callback: (event: FileEvent) => void, +export async function subscribeToAppEvents( + callback: (event: AppEvent) => void, ): Promise { - return listen("file-event", (tauriEvent) => { + return listen("app-event", (tauriEvent) => { callback(tauriEvent.payload); }); } /** List all active tags. */ -export function listTags(): ResultAsync { +export function listTags(): ResultAsync { return fromInvoke("list_tags", {}); } @@ -137,7 +194,7 @@ export function listTags(): ResultAsync { export function attachTag( hash: string, tagName: string, -): ResultAsync { +): ResultAsync { return fromInvoke("attach_tag", { hash, tagName }); } @@ -145,7 +202,7 @@ export function attachTag( export function detachTag( hash: string, tagId: string, -): ResultAsync { +): ResultAsync { return fromInvoke("detach_tag", { hash, tagId }); } @@ -153,7 +210,7 @@ export function detachTag( export function listFilesWithTags( limit: number, volume?: string, -): ResultAsync { +): ResultAsync { return fromInvoke("list_files_with_tags", { limit, volume: volume ?? null }); } @@ -166,11 +223,11 @@ export function listFilesWithTags( export function search( query: string, limit = 50, -): ResultAsync { +): ResultAsync { return fromInvoke("search", { query, limit }); } /** Wipe and rebuild the FTS5 search index from the current DB state. */ -export function searchRebuild(): ResultAsync { +export function searchRebuild(): ResultAsync { return fromInvoke("search_rebuild", {}); } diff --git a/apps/desktop/src/bindings.ts b/apps/desktop/src/bindings.ts new file mode 100644 index 0000000..cc74fcc --- /dev/null +++ b/apps/desktop/src/bindings.ts @@ -0,0 +1,231 @@ +// This file has been generated by Specta. DO NOT EDIT. +// HAND-CRAFTED PLACEHOLDER (Batch D): regenerated by CI on first +// `cargo build --features specta-export` run; bindings-drift CI job +// (Task 12) gates future regeneration. Constructed analytically from +// the post-Batch-D Rust types via specta translation rules in spec §4.2 +// + §3 row K. Field names are Rust snake_case (no rename_all applied). + +// ── Primitive newtypes ──────────────────────────────────────────────── + +/** + * BLAKE3-256 content hash as 64-char lowercase hex. + * Rust: `BlakeHash([u8; 32])` with custom `Serialize` → string. + */ +export type BlakeHash = string; + +/** + * File size in bytes. + * Rust: `FileSize(pub u64)` — newtype tuple; serde emits as number. + */ +export type FileSize = number; + +/** + * Path relative to a volume root (forward-slash, no leading slash). + * Rust: `MediaPath(String)` with `#[specta(transparent)]`. + */ +export type MediaPath = string; + +/** + * UUIDv7 volume identifier. + * Rust: `VolumeId(pub uuid::Uuid)` with `#[specta(transparent)]`. + */ +export type VolumeId = string; + +/** + * UUIDv7 device identifier. + * Rust: `DeviceId(pub uuid::Uuid)` with `#[specta(transparent)]`. + */ +export type DeviceId = string; + +// ── Enums ──────────────────────────────────────────────────────────── + +/** + * Top-level event envelope emitted by the backend on the `app-event` channel. + * Rust: `AppEvent` with `#[serde(tag = "kind", content = "data")]`. + * + * HAND-CRAFTED (Batch E Task 12): env-limited, cannot run + * `cargo build --features specta-export` locally; CI bindings-drift + * job gates regeneration. + */ +export type AppEvent = + | { kind: "File"; data: FileEvent } + | { + kind: "ScanCompleted"; + data: { + volume: VolumeId; + files_seen: number; + files_new: number; + duration_ms: number; + }; + } + | { kind: "IndexInvalidated"; data: { reason: InvalidationReason } }; + +/** + * Categorical reason an index was invalidated. Inner field of + * AppEvent::IndexInvalidated.data.reason. Wire shape (the whole + * envelope): `\{kind: "IndexInvalidated", data: \{reason: "TagsChanged"\}\}`. + * + * WHY string union (not discriminated union): commit b00d1ad dropped + * `#[serde(tag = "reason")]`; default Rust enum serde produces bare strings + * for the inner InvalidationReason value. The outer IndexInvalidated variant + * is a struct variant with a named `reason` field, so #[serde(content = "data")] + * wraps the whole struct (\{reason: ...\}) into data — not just the bare string. + * Wire-shape oracle: crates/core/tests/serialize_shape.rs:303-314 asserts + * v["data"]["reason"] == "TagsChanged". + */ +export type InvalidationReason = + | "TagsChanged" + | "FilesChanged" + | "MetadataChanged" + | "SearchIndexRebuilt"; + +/** + * Status of a file location row. + * Rust: plain unit-variant enum, no serde renaming. + */ +export type LocationStatus = "Active" | "Missing" | "Moved" | "Stale"; + +/** + * Filesystem event emitted by the backend watcher. + * Rust: `#[serde(tag = "type")]` inline-tagged enum. + */ +export type FileEvent = + | { type: "Created"; path: MediaPath; volume: VolumeId } + | { type: "Modified"; path: MediaPath; volume: VolumeId } + | { type: "Deleted"; path: MediaPath; volume: VolumeId } + | { type: "Renamed"; from: MediaPath; to: MediaPath; volume: VolumeId }; + +/** + * Typed error returned by all Tauri command handlers. + * Rust: `CoreError` with `#[serde(tag = "kind", content = "data")]`. + */ +export type CoreError = + | { kind: "NotFound"; data: string } + | { kind: "Duplicate"; data: string } + | { kind: "InvalidPath"; data: string } + | { kind: "InvalidHash"; data: string } + | { kind: "InvalidTag"; data: string } + | { kind: "Io"; data: { kind: string; message: string } } + | { kind: "Unsupported"; data: string } + | { kind: "Internal"; data: string }; + +// ── Structs ────────────────────────────────────────────────────────── + +/** + * A file location row — joined view of `files` + `file_locations`. + * Rust: `crates/core/src/types.rs::FileLocationRecord`. + */ +export type FileLocationRecord = { + hash: BlakeHash; + size: FileSize; + volume_id: VolumeId; + relative_path: MediaPath; + status: LocationStatus; + first_seen: string; +}; + +/** + * A storage volume known to perima. + * Rust: `crates/core/src/types.rs::VolumeRecord`. + * Note: `mounts_on_this_machine` wraps `Vec` → `string[]`. + */ +export type VolumeRecord = { + id: VolumeId; + label: string | null; + capacity_bytes: number; + is_removable: boolean; + mounts_on_this_machine: string[]; + last_seen: string; +}; + +/** + * Media metadata extracted from a file. + * Rust: `crates/core/src/metadata.rs::MediaMetadata`. + */ +export type MediaMetadata = { + hash: BlakeHash; + width: number | null; + height: number | null; + duration_ms: number | null; + captured_at: string | null; + camera_make: string | null; + camera_model: string | null; + codec: string | null; + bitrate_bps: number | null; + mime_type: string | null; + thumbnail_path: string | null; + thumbnail_status: string | null; +}; + +/** + * A user-assignable tag on content. + * Rust: `crates/core/src/tag.rs::Tag`. + */ +export type Tag = { + id: string; + name: string; + first_seen: string; +}; + +/** + * A ranked result from the FTS5 full-text search index. + * Rust: `crates/core/src/search.rs::SearchHit`. + */ +export type SearchHit = { + blake3_hash: string; + volume_id: string; + relative_path: string; + rank: number; +}; + +/** + * Aggregate scan report returned by the `scan` command. + * Rust: `crates/app/src/scan.rs::ScanReport`. + * Fields with `#[serde(skip)]` are omitted (`volume_mount`, + * `per_file_entries`, `manifest_files`). + */ +export type ScanReport = { + files_seen: number; + files_new: number; + files_updated: number; + files_errored: number; + bytes_hashed: number; + duration_ms: number; + interrupted: boolean; + volume_label: string | null; +}; + +/** + * File location joined with extracted media metadata. + * Rust: `crates/desktop/src/payloads.rs::FileWithMetadataPayload`. + * Shell-side flat composite — all fields present, metadata fields + * nullable when no `file_metadata` row exists. + */ +export type FileWithMetadataPayload = { + hash: string; + size: number; + volume_id: string; + relative_path: string; + status: string; + first_seen: string; + width: number | null; + height: number | null; + duration_ms: number | null; + captured_at: string | null; + camera_make: string | null; + camera_model: string | null; + codec: string | null; + bitrate_bps: number | null; + mime_type: string | null; + thumbnail_path: string | null; + thumbnail_status: string | null; +}; + +/** + * File with metadata plus its attached tags. + * Rust: `crates/desktop/src/payloads.rs::FileWithTagsPayload` + * with `#[serde(flatten)]` on the `file` field. + */ +export type FileWithTagsPayload = FileWithMetadataPayload & { + tags: Tag[]; +}; diff --git a/apps/desktop/src/components/FileGrid.tsx b/apps/desktop/src/components/FileGrid.tsx index 33b7cdb..1a26af9 100644 --- a/apps/desktop/src/components/FileGrid.tsx +++ b/apps/desktop/src/components/FileGrid.tsx @@ -1,11 +1,11 @@ import { convertFileSrc } from "@tauri-apps/api/core"; -import type { FileWithTags } from "../types"; +import type { FileWithTagsPayload } from "../bindings"; import TagChip from "./TagChip"; /** Props for {@link FileGrid}. */ interface FileGridProps { /** Rows to render as grid tiles. */ - files: FileWithTags[]; + files: FileWithTagsPayload[]; /** When true, show a loading indicator instead of tiles. */ loading?: boolean; } @@ -50,7 +50,7 @@ export default function FileGrid({ files, loading = false }: FileGridProps) { } /** Single grid tile rendering either a thumbnail or a placeholder. */ -function FileGridTile({ file }: { file: FileWithTags }) { +function FileGridTile({ file }: { file: FileWithTagsPayload }) { const ready = file.thumbnail_status === "ready" && file.thumbnail_path !== null; const filename = file.relative_path.split("/").pop() ?? file.relative_path; diff --git a/apps/desktop/src/components/FileTable.tsx b/apps/desktop/src/components/FileTable.tsx index 9cf551f..5a002a8 100644 --- a/apps/desktop/src/components/FileTable.tsx +++ b/apps/desktop/src/components/FileTable.tsx @@ -1,11 +1,11 @@ import { useState } from "react"; -import type { FileWithTags } from "../types"; +import type { FileWithTagsPayload } from "../bindings"; import TagChip from "./TagChip"; /** Props for {@link FileTable}. */ interface FileTableProps { /** Rows to display. */ - files: FileWithTags[]; + files: FileWithTagsPayload[]; /** When true, show a loading indicator instead of the table body. */ loading: boolean; } diff --git a/apps/desktop/src/components/ScanButton.tsx b/apps/desktop/src/components/ScanButton.tsx index 71fb5f7..22a50bd 100644 --- a/apps/desktop/src/components/ScanButton.tsx +++ b/apps/desktop/src/components/ScanButton.tsx @@ -1,6 +1,7 @@ import { open as openDialog } from "@tauri-apps/plugin-dialog"; import * as api from "../api"; -import type { ScanResult } from "../types"; +import { coreErrorMessage } from "../lib/coreError"; +import type { ScanReport } from "../bindings"; /** Props for {@link ScanButton}. */ interface ScanButtonProps { @@ -11,7 +12,7 @@ interface ScanButtonProps { * WHY path is passed: the parent needs it to auto-start the filesystem * watcher on the folder that was just scanned (phase 3b). */ - onScanComplete: (result: ScanResult, path: string) => void; + onScanComplete: (result: ScanReport, path: string) => void; /** Called immediately before the scan starts (use to set loading state). */ onScanStart: () => void; /** When true, show the disabled "Scanning..." state. */ @@ -37,7 +38,9 @@ export default function ScanButton({ onScanStart(); void api.scan(selected, false).match( (result) => { onScanComplete(result, selected); }, - (err) => { window.alert(`Scan failed: ${err}`); }, + // WHY coreErrorMessage: helper centralises the data-payload stringification + // (plain string vs Io's { kind, message } struct) with cyclic-object safety. + (err) => { window.alert(`Scan failed [${err.kind}]: ${coreErrorMessage(err)}`); }, ); } diff --git a/apps/desktop/src/components/SearchBar.tsx b/apps/desktop/src/components/SearchBar.tsx index 9eefdc1..dd5618e 100644 --- a/apps/desktop/src/components/SearchBar.tsx +++ b/apps/desktop/src/components/SearchBar.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import * as api from "../api"; import { buildFtsQuery } from "../lib/search"; -import type { SearchHit } from "../types"; +import type { SearchHit } from "../bindings"; /** Milliseconds to wait after the user stops typing before firing a search. */ const DEBOUNCE_MS = 300; diff --git a/apps/desktop/src/components/StatusBar.tsx b/apps/desktop/src/components/StatusBar.tsx index 7487b5f..6505010 100644 --- a/apps/desktop/src/components/StatusBar.tsx +++ b/apps/desktop/src/components/StatusBar.tsx @@ -1,11 +1,53 @@ -import type { ScanResult } from "../types"; +import type { CoreError, ScanReport } from "../bindings"; /** Props for {@link StatusBar}. */ interface StatusBarProps { - /** Most recent scan result, or null if no scan has run. */ - scanResult: ScanResult | null; - /** Current error message, or null if none. */ - error: string | null; + /** Most recent scan report, or null if no scan has run. */ + scanResult: ScanReport | null; + /** + * Current error, or null if none. + * WHY CoreError not string: api.ts surfaces typed errors from the backend + * discriminated union; the switch(error.kind) below renders distinct UX + * per variant (Task 11). + */ + error: CoreError | null; +} + +/** + * Renders a human-readable label for a {@link CoreError} discriminated union. + * + * WHY distinct NotFound branch: "No results found." is user-facing vocabulary + * for a search miss — not a system error. All other variants share the generic + * "Something went wrong" phrasing that conveys unexpected failure. The + * TypeScript `never` default forces a compile error if a new CoreError variant + * is added without updating this switch. + */ +function renderError(error: CoreError): React.ReactNode { + switch (error.kind) { + case "NotFound": + return "No results found."; + case "Internal": + case "Io": + case "Duplicate": + case "InvalidPath": + case "InvalidHash": + case "InvalidTag": + case "Unsupported": { + // WHY: Io carries { kind, message }; all others carry a plain string. + const detail = + error.data instanceof Object + ? (error.data as { message: string }).message + : error.data; + return `Something went wrong: ${detail}`; + } + default: { + // WHY exhaustive default with `never`: TypeScript verifies every + // CoreError variant is handled at compile time — adding a new variant + // without updating this switch is a compile error. + const _exhaustive: never = error; + return `Unknown error (${(_exhaustive as CoreError).kind})`; + } + } } /** @@ -17,7 +59,7 @@ export default function StatusBar({ scanResult, error }: StatusBarProps) { if (error) { return (
- {error} + {renderError(error)}
); } @@ -25,7 +67,7 @@ export default function StatusBar({ scanResult, error }: StatusBarProps) { if (scanResult) { return (
- {`scanned ${scanResult.total} files (${scanResult.new} new, ${scanResult.existing} existing, ${scanResult.errors} errors)`} + {`scanned ${scanResult.files_seen} files (${scanResult.files_new} new, ${scanResult.files_updated} updated, ${scanResult.files_errored} errors)`}
); } diff --git a/apps/desktop/src/components/TagChip.tsx b/apps/desktop/src/components/TagChip.tsx index 1903dfc..0f155a1 100644 --- a/apps/desktop/src/components/TagChip.tsx +++ b/apps/desktop/src/components/TagChip.tsx @@ -1,4 +1,4 @@ -import type { Tag } from "../types"; +import type { Tag } from "../bindings"; /** Props for {@link TagChip}. */ interface TagChipProps { diff --git a/apps/desktop/src/components/TagSidebar.tsx b/apps/desktop/src/components/TagSidebar.tsx index a718576..9483d02 100644 --- a/apps/desktop/src/components/TagSidebar.tsx +++ b/apps/desktop/src/components/TagSidebar.tsx @@ -1,4 +1,4 @@ -import type { Tag } from "../types"; +import type { Tag } from "../bindings"; interface TagSidebarProps { /** Full tag list (all known tags). */ diff --git a/apps/desktop/src/lib/__tests__/fixtures.ts b/apps/desktop/src/lib/__tests__/fixtures.ts index 2cb3e2b..a834b12 100644 --- a/apps/desktop/src/lib/__tests__/fixtures.ts +++ b/apps/desktop/src/lib/__tests__/fixtures.ts @@ -1,7 +1,7 @@ -import type { FileWithTags } from "../../types"; +import type { FileWithTagsPayload } from "../../bindings"; /** - * Construct a FileWithTags fixture for unit tests. + * Construct a FileWithTagsPayload fixture for unit tests. * * WHY shared: the same fixture shape is needed by search.test.ts * (computeFacets, composeVisible, sortByRank tests) and later by @@ -10,7 +10,7 @@ import type { FileWithTags } from "../../types"; * Only the two fields that matter for filter/compose logic (`hash` and * `tags[].id`) take real values; everything else is a neutral default. */ -export function file(hash: string, tagIds: string[]): FileWithTags { +export function file(hash: string, tagIds: string[]): FileWithTagsPayload { return { hash, size: 0, diff --git a/apps/desktop/src/lib/coreError.ts b/apps/desktop/src/lib/coreError.ts new file mode 100644 index 0000000..da3aedd --- /dev/null +++ b/apps/desktop/src/lib/coreError.ts @@ -0,0 +1,23 @@ +import type { CoreError } from "../bindings"; + +/** + * Returns a human-readable string from a {@link CoreError} data payload. + * + * WHY helper: three call sites (App.tsx, ScanButton.tsx, StatusBar.tsx) all + * need to stringify the `data` field, which is either a plain string or the + * structured `{ kind, message }` object carried by Io errors. Centralising the + * logic eliminates duplicated inline ternaries and adds cyclic-object safety + * (JSON.stringify throws on cyclic inputs — guarded by try/catch). + */ +export function coreErrorMessage(e: CoreError): string { + if (typeof e.data === "string") { + return e.data; + } + // WHY try/catch: JSON.stringify throws on cyclic objects. In practice + // CoreError payloads are plain structs from Rust, but we guard defensively. + try { + return JSON.stringify(e.data); + } catch { + return "[unserializable error data]"; + } +} diff --git a/apps/desktop/src/lib/search.ts b/apps/desktop/src/lib/search.ts index 2d28b74..40b95b6 100644 --- a/apps/desktop/src/lib/search.ts +++ b/apps/desktop/src/lib/search.ts @@ -1,4 +1,4 @@ -import type { FileWithTags } from "../types"; +import type { FileWithTagsPayload } from "../bindings"; /** * Search-related pure functions. No React imports; test directly. @@ -94,7 +94,7 @@ function stripUnsafe(s: string): string { * * Unordered; callers sort if display order matters. */ -export function computeFacets(files: FileWithTags[]): Record { +export function computeFacets(files: FileWithTagsPayload[]): Record { const counts: Record = {}; for (const f of files) { for (const t of f.tags) { @@ -117,10 +117,10 @@ export function computeFacets(files: FileWithTags[]): Record { * is the INTERSECTION, not one overriding the other. */ export function composeVisible( - files: FileWithTags[], + files: FileWithTagsPayload[], selectedTagId: string | null, searchHits: Set | null, -): FileWithTags[] { +): FileWithTagsPayload[] { return files.filter((f) => { if (selectedTagId !== null && !f.tags.some((t) => t.id === selectedTagId)) { return false; @@ -140,11 +140,11 @@ export function composeVisible( * non-null), so the fallback only fires on defensive misuse. */ export function sortByRank( - files: FileWithTags[], + files: FileWithTagsPayload[], hitRanks: Map, -): FileWithTags[] { - const ranked: Array<{ file: FileWithTags; rank: number }> = []; - const unranked: FileWithTags[] = []; +): FileWithTagsPayload[] { + const ranked: Array<{ file: FileWithTagsPayload; rank: number }> = []; + const unranked: FileWithTagsPayload[] = []; for (const f of files) { const r = hitRanks.get(f.hash); if (r === undefined) unranked.push(f); diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts deleted file mode 100644 index cdfd9db..0000000 --- a/apps/desktop/src/types.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** Result returned by a scan command. */ -export interface ScanResult { - /** Total files processed. */ - total: number; - /** Files added for the first time. */ - new: number; - /** Files already known to the database. */ - existing: number; - /** Files that produced errors during scanning. */ - errors: number; -} - -/** - * A file location record — the joined view of `files` + `file_locations`. - */ -export interface FileEntry { - /** 64-char lowercase hex SHA-256 hash. */ - hash: string; - /** File size in bytes. */ - size: number; - /** UUID of the volume that contains this file. */ - volume_id: string; - /** Path relative to the volume root. */ - relative_path: string; - /** Lifecycle status: "active" | "missing" | "moved". */ - status: string; - /** ISO 8601 timestamp of first indexing. */ - first_seen: string; -} - -/** - * A file location joined with any extracted media metadata. - * - * Metadata fields are all independently nullable — a location without a - * matching `file_metadata` row surfaces with every metadata column as - * `null`, which the UI should treat as "pending extraction", not - * "no metadata exists". - */ -export interface FileWithMetadata { - /** 64-char lowercase hex BLAKE3 hash. */ - hash: string; - /** File size in bytes. */ - size: number; - /** UUID of the volume that contains this file. */ - volume_id: string; - /** Path relative to the volume root. */ - relative_path: string; - /** Lifecycle status: "active" | "missing" | "moved" | "stale". */ - status: string; - /** ISO 8601 timestamp of first indexing. */ - first_seen: string; - /** Pixel width (images / video). */ - width: number | null; - /** Pixel height (images / video). */ - height: number | null; - /** Duration in milliseconds (video / audio). */ - duration_ms: number | null; - /** ISO 8601 UTC capture timestamp. */ - captured_at: string | null; - /** Camera manufacturer (EXIF `Make`). */ - camera_make: string | null; - /** Camera model (EXIF `Model`). */ - camera_model: string | null; - /** Codec identifier (e.g. "avc1", "hevc"). */ - codec: string | null; - /** Overall bitrate in bits per second. */ - bitrate_bps: number | null; - /** MIME type as detected at extraction time. */ - mime_type: string | null; - /** - * Absolute on-disk path to the generated WebP thumbnail. `null` until - * the backend queue worker has finished writing one (or if generation - * failed, in which case {@link thumbnail_status} is `"failed"`). - */ - thumbnail_path: string | null; - /** - * Thumbnail lifecycle: `"pending"`, `"ready"`, `"failed"`, or `null` - * for metadata rows that predate v0.4.1. - */ - thumbnail_status: string | null; -} - -/** A storage volume known to perima. */ -export interface VolumeEntry { - /** UUIDv7 primary key. */ - id: string; - /** User-visible label, if any. */ - label: string | null; - /** Total capacity in bytes. */ - capacity_bytes: number; - /** Whether the volume is removable (USB, etc.). */ - is_removable: boolean; - /** Mount-point paths seen on this machine. */ - mounts_on_this_machine: string[]; - /** ISO 8601 timestamp of last observation. */ - last_seen: string; -} - -/** A user-assignable tag on content. */ -export interface Tag { - /** UUIDv7 primary key. */ - id: string; - /** NFC-normalized lowercase name. */ - name: string; - /** ISO 8601 UTC timestamp of first sighting. */ - first_seen: string; -} - -/** File-with-metadata plus its attached tags. */ -export interface FileWithTags extends FileWithMetadata { - /** Tags attached to this content hash. */ - tags: Tag[]; -} - -/** A ranked result from the FTS5 full-text search index. */ -export interface SearchHit { - /** BLAKE3 hex hash of the file content. */ - blake3_hash: string; - /** Volume UUID. */ - volume_id: string; - /** Relative path within the volume. */ - relative_path: string; - /** BM25 rank — lower (more negative) means better match. */ - rank: number; -} - -/** - * Filesystem event emitted by the backend watcher. - * - * WHY discriminated union with literal `type` tag: matches the Rust - * `FileEventPayload` which uses `#[serde(tag = "type")]`. TypeScript - * can narrow by `switch (e.type)` the same way Rust matches the enum. - */ -export type FileEvent = - | { type: "Created"; path: string; volume: string } - | { type: "Modified"; path: string; volume: string } - | { type: "Deleted"; path: string; volume: string } - | { type: "Renamed"; from: string; to: string; volume: string }; diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml new file mode 100644 index 0000000..c30d843 --- /dev/null +++ b/crates/app/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "perima-app" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false # WHY: intra-workspace only; not published to crates.io + +[features] +# WHY specta feature: `ScanReport` needs `specta::Type` when the desktop +# handler returns it across the IPC boundary. Mirrors the pattern in +# `crates/core` (optional dep, cfg_attr gated). Activated by +# `perima-desktop` via `perima-app = { features = ["specta"] }`. +specta = ["dep:specta", "perima-core/specta"] + +[dependencies] +perima-core = { path = "../core" } +perima-fs = { path = "../fs" } +perima-hash = { path = "../hash" } +perima-media = { workspace = true } +async-broadcast = { workspace = true } +async-trait = { workspace = true } +futures = { workspace = true } +tokio.workspace = true +tokio-util.workspace = true +thiserror.workspace = true +tracing.workspace = true +# WHY serde: ScanReport + ScanReportEntry derive Serialize so the +# desktop handler can return the UseCase output directly across the +# IPC boundary (Batch D Task 8 — no shell-side ScanResult mirror). +serde.workspace = true +# WHY rayon: ScanUseCase parallelizes BLAKE3 hashing across CPUs via +# `par_iter`. Matches the pattern in `crates/cli/src/cmd/scan.rs` +# pre-extraction; Batch B preserves the exact orchestration shape. +rayon.workspace = true +# WHY uuid: VolumeId fallback in dry-run path uses `Uuid::nil()`. +uuid.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 } + +[dev-dependencies] +tempfile.workspace = true +proptest.workspace = true +insta.workspace = true +# WHY perima-db + rusqlite in dev-deps: integration-flavored unit tests +# construct real SQLite-backed adapters (matching crates/db::file_repo +# tests) rather than invent new mock ports. DB is NOT a runtime dep of +# crates/app — shells inject `Arc` adapters. +perima-db = { path = "../db" } +rusqlite.workspace = true + +[lints] +workspace = true diff --git a/crates/app/src/bus.rs b/crates/app/src/bus.rs new file mode 100644 index 0000000..bcfb65f --- /dev/null +++ b/crates/app/src/bus.rs @@ -0,0 +1,120 @@ +//! Application-wide event bus backed by `async_broadcast`. +//! +//! Replaces the synchronous `CompositeEventBus` (deleted in Task 6). +//! Single construction site: [`crate::container::AppContainer::new`]. + +use std::sync::Arc; + +use async_broadcast::{InactiveReceiver, Receiver, Sender, TrySendError, broadcast}; +use perima_core::{AppEvent, CoreError, EventBus}; + +/// Bus's bounded shared-buffer capacity. Backpressure mode (default — +/// `set_overflow(false)`): when the buffer is saturated, `try_broadcast` +/// returns `Err(Full)` (mapped to `Ok(())` with a warn log in `Bus::emit`). +/// Receivers track their own cursor into the shared ring buffer; the +/// next `recv()` simply yields the next un-dropped event. Bus's +/// backpressure mode never surfaces `RecvError::Overflowed(n)` — that +/// path only fires under `set_overflow(true)` (overflow mode), which +/// the raw-channel test `receiver_overflowed_keeps_going` exercises +/// to document the contract async-broadcast provides for late-joiner +/// replay (umbrella spec §A7 future work). +/// +/// Capacity 256 is the umbrella spec §A7 number; a fast subscriber +/// drains 256 in milliseconds. +const CAPACITY: usize = 256; + +/// Application-wide event bus. +/// +/// WHY no ring buffer: deferred per spec §2.2 OUT (umbrella §A7 +/// requirement, but no v1 late-joiner consumer exists). Add when first +/// late-joiner ships (likely Phase 6 mobile / HTTP shell). See GH #129. +pub struct Bus { + sender: Sender, + /// WHY `InactiveReceiver`: async-broadcast closes the channel when + /// the last active receiver drops. We hold an inactive receiver + /// alive so `Bus::emit` doesn't get `TrySendError::Closed` between + /// the moment a handler task drops and the next is spawned. + _inactive: InactiveReceiver, +} + +// WHY allow: `InactiveReceiver` does not implement `Debug`, so `_inactive` +// cannot be included. The field intentionally omitted — it is a channel anchor +// with no user-visible state beyond "present/absent". +#[allow(clippy::missing_fields_in_debug)] +impl std::fmt::Debug for Bus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Bus") + .field("capacity", &CAPACITY) + .field("receivers", &self.sender.receiver_count()) + .finish() + } +} + +impl Bus { + /// Construct a fresh bus with a `CAPACITY`-sized shared ring buffer. + /// Holds an inactive receiver alive so the channel doesn't close + /// when subscribers drop. + #[must_use] + pub fn new() -> Arc { + let (sender, receiver) = broadcast(CAPACITY); + let inactive = receiver.deactivate(); + Arc::new(Self { + sender, + _inactive: inactive, + }) + } + + /// Subscribe to the bus. Returns a fresh `Receiver` whose cursor + /// tracks the shared ring buffer. Drop the receiver to unsubscribe. + #[must_use] + pub fn subscribe(&self) -> Receiver { + self.sender.new_receiver() + } + + /// Number of active receivers — useful for tests + introspection. + #[must_use] + pub fn receiver_count(&self) -> usize { + self.sender.receiver_count() + } +} + +impl EventBus for Bus { + fn emit(&self, event: &AppEvent) -> Result<(), CoreError> { + // WHY try_broadcast (sync) not broadcast (async): writer thread + // (Batch C) is on std::thread::spawn, NOT on the tokio runtime. + // Async emit would force block_on with a runtime handle, which + // risks deadlock if any subscriber panics. Trade-off documented + // in spec §3 row G + §2.2 OUT. + match self.sender.try_broadcast(event.clone()) { + Ok(_) => Ok(()), + Err(TrySendError::Full(_)) => { + tracing::warn!( + event_kind = event_kind(event), + receivers = self.sender.receiver_count(), + "broadcast inbox full; subscriber too slow" + ); + Ok(()) + } + Err(TrySendError::Closed(_)) => { + tracing::info!("bus closed (no active subscribers)"); + Ok(()) + } + Err(TrySendError::Inactive(_)) => { + // WHY: we hold an InactiveReceiver alive in self._inactive, + // so this arm should be unreachable in practice. Log loudly + // if it ever fires — it indicates the inactive-receiver + // invariant was broken. + tracing::error!("bus has zero receivers (impossible state)"); + Ok(()) + } + } + } +} + +const fn event_kind(e: &AppEvent) -> &'static str { + match e { + AppEvent::File(_) => "File", + AppEvent::ScanCompleted { .. } => "ScanCompleted", + AppEvent::IndexInvalidated { .. } => "IndexInvalidated", + } +} diff --git a/crates/app/src/container.rs b/crates/app/src/container.rs new file mode 100644 index 0000000..7eba37b --- /dev/null +++ b/crates/app/src/container.rs @@ -0,0 +1,419 @@ +//! `AppContainer` — the single dependency hub CLI + Desktop + future +//! axum/plugin shells consume. Clone is cheap (all fields are Arc). +//! +//! # Shape +//! +//! - [`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. +//! +//! # Event-bus wiring (Batch E Task 6) +//! +//! [`AppContainer::new`] builds a single [`Bus`] (the canonical +//! single-construction-site invariant from Batch B), assigns +//! `bus.clone()` to `events` (the `Arc` shared with every +//! `UseCase`), and spawns one tokio task per registered +//! [`EventHandler`] running `crate::events::recv_loop`. Each task +//! owns its own broadcast `Receiver` cursor; tasks exit when the bus +//! closes (container drop). + +use std::sync::Arc; + +use perima_core::{ + FileRepository, HashService, MetadataRepository, Scanner, SearchRepository, TagRepository, + VolumeRepository, events::EventBus, +}; +use perima_media::ThumbnailGenerator; + +use crate::{ + Bus, MetadataUseCase, ScanUseCase, SearchUseCase, TagUseCase, VolumeUseCase, + events::EventHandler, +}; + +// --------------------------------------------------------------------------- +// AppDeps +// --------------------------------------------------------------------------- + +/// Flat dependency-injection struct. Shells build one, hand it to +/// [`AppContainer::new`]. +/// +/// # 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. +#[derive(Clone)] +pub struct AppDeps { + /// File + location repository port. + pub files: Arc, + /// Volume repository port. + pub volumes: Arc, + /// Tag repository port. + pub tags: Arc, + /// Metadata repository port (media metadata, not filesystem meta). + pub metadata: Arc, + /// Search repository port (FTS5-backed in the live adapter). + pub search: Arc, + /// Content-hash service port. + pub hasher: Arc, + /// Filesystem walker port. + pub scanner: Arc, + /// Concrete thumbnail generator (not a port — no abstraction yet). + pub thumbnailer: Arc, +} + +impl std::fmt::Debug for AppDeps { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // WHY: every field is a trait object / concrete adapter + // without `Debug`; printing just the type name keeps traces + // useful without widening any port trait. + f.write_str("AppDeps { .. }") + } +} + +// --------------------------------------------------------------------------- +// AppContainer +// --------------------------------------------------------------------------- + +/// Application-service root. axum `with_state` + Tauri `manage` both +/// accept this trivially thanks to `Clone` + `Arc` fields. +/// +/// # Why `Arc` and not `Arc` +/// +/// Callers (and tests) may occasionally want to swap in a non-`Bus` +/// implementor (e.g., a stub for unit tests). Exposing the trait +/// object keeps the container type stable across those configurations. +#[derive(Clone)] +pub struct AppContainer { + /// [`ScanUseCase`] — full + incremental scan orchestration. + pub scan: Arc, + /// [`SearchUseCase`] — FTS5-backed full-text search. + pub search: Arc, + /// [`TagUseCase`] — attach/detach/list/list-files-with-tags. + pub tag: Arc, + /// [`VolumeUseCase`] — create/list/delete/find-by-id/path. + pub volume: Arc, + /// [`MetadataUseCase`] — list files + attached metadata. + pub metadata: Arc, + /// Shared event bus — same `Arc` used inside every `UseCase`. + pub events: Arc, + /// Direct handle to the volume repository port. + /// + /// WHY exposed (post-Batch-C Task 2): shell sites that need + /// `find_or_create` (scan / watch startup) operate outside the + /// [`VolumeUseCase`] surface — the `UseCase` deliberately only exposes + /// `List` + `RecordMount`. Before Batch C, those sites opened a + /// short-lived `SqliteVolumeRepository::new(conn)` with their own + /// `rusqlite::Connection`; with the writer actor in place there is + /// exactly one writer per process, so every shell site shares the + /// same adapter handle via this field. The `Arc` + /// type keeps the container decoupled from the concrete adapter + /// (same pattern as `AppDeps::volumes`). + pub volumes: Arc, + /// Direct handle to the tag repository port. + /// + /// WHY exposed (post-Batch-C Task 3): shell sites use + /// `TagRepository::count_files_for_tag` and `files_with_tag` + /// directly (CLI `tag ls` counts, CLI `ls --tag` filter); those + /// methods are not exposed through [`TagUseCase`] today. Before + /// Batch C, each of those sites opened a short-lived + /// `SqliteTagRepository::new(conn)`. With the writer actor owning + /// the sole writable connection, every shell site shares one + /// adapter handle via this field. Same pattern / rationale as + /// `volumes` above. + pub tags: Arc, + /// Direct handle to the metadata repository port. + /// + /// WHY exposed (post-Batch-C Task 4): `perima metadata ` + /// spawns a [`perima_media::MetadataQueue`] and clones the + /// `Arc` into the background worker. The + /// `MetadataUseCase` deliberately exposes only list-style commands + /// (`ListFiles` / `ListFilesWithMetadata`) — the re-extraction flow + /// is interactive and stays outside the use-case surface. Before + /// Batch C, the CLI opened a short-lived + /// `SqliteMetadataRepository::new(conn)` for the worker; with the + /// writer actor owning the sole writable connection, every shell + /// site shares one adapter handle via this field. Same pattern / + /// rationale as `volumes` + `tags` above. + pub metadata_repo: Arc, + /// Direct handle to the file repository port. + /// + /// WHY exposed (post-Batch-C Task 7): CLI `tag add/rm` and + /// `metadata ` resolve a filesystem path to a `BlakeHash` by + /// calling `FileRepository::list_file_locations`. Those paths operate + /// outside the `UseCase` surface. Each such callsite used to open a + /// short-lived `Mutex`-backed adapter; the writer actor + /// now owns the sole writable connection, so every shell site shares + /// this single adapter handle. Same pattern as `volumes`, `tags`, + /// `metadata_repo` above. + pub files_repo: Arc, +} + +impl std::fmt::Debug for AppContainer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // WHY: UseCase structs + `dyn EventBus` lack `Debug`; keep + // the type name so `#[tracing::instrument]` spans with + // `state = ?container` render something useful. + f.write_str("AppContainer { .. }") + } +} + +impl AppContainer { + /// Build the container from flat deps + the shell's chosen + /// event handlers. + /// + /// Wiring (Batch E Task 6): + /// + /// 1. Constructs a single [`Bus`] — the canonical + /// single-construction-site invariant carried over from Batch B's + /// `CompositeEventBus`. + /// 2. Sets `events = bus.clone()` (coerces `Arc` to + /// `Arc` because `Bus: EventBus`). + /// 3. For each handler: subscribes a fresh `Receiver` and + /// `tokio::spawn`s `crate::events::recv_loop` for it. Each task + /// runs until the bus closes (container drop). + /// + /// Pass `vec![]` to skip listeners (unit tests, dry runs, or shells + /// that don't need any event reaction). + /// + /// # Panics + /// + /// Must be called from within a tokio runtime context — `tokio::spawn` + /// requires it. Both shells (CLI `#[tokio::main]`, Desktop via + /// Tauri's runtime) satisfy this. + #[must_use] + // WHY: `deps` is consumed conceptually — the shell hands its DI + // bundle to the container at startup and the outer `AppDeps` is + // dropped after this call. Taking `&AppDeps` would force callers + // to keep the bundle alive pointlessly. Every field is an `Arc`, + // so by-value move here is cheap. + #[allow(clippy::needless_pass_by_value)] + pub fn new(deps: AppDeps, handlers: Vec>) -> Arc { + // Single Bus construction site — spec §2.1 + Batch B/C invariant. + let bus: Arc = Bus::new(); + let events: Arc = bus.clone(); + + // Spawn one tokio task per handler. Each task owns its own + // Receiver and runs the shared recv_loop until the bus closes + // (container drop, when all Sender clones release). + for handler in handlers { + let name = handler.name(); + let recv = bus.subscribe(); + tokio::spawn(crate::events::recv_loop(name, handler, recv)); + } + + let scan = Arc::new(ScanUseCase::new( + Arc::clone(&deps.files), + Arc::clone(&deps.volumes), + Arc::clone(&deps.metadata), + Arc::clone(&deps.scanner), + Arc::clone(&deps.hasher), + Arc::clone(&deps.thumbnailer), + Arc::clone(&events), + )); + let search = Arc::new(SearchUseCase::new( + Arc::clone(&deps.search), + Arc::clone(&events), + )); + let tag = Arc::new(TagUseCase::new( + Arc::clone(&deps.tags), + Arc::clone(&deps.metadata), + Arc::clone(&events), + )); + let volume = Arc::new(VolumeUseCase::new( + Arc::clone(&deps.volumes), + Arc::clone(&events), + )); + let metadata = Arc::new(MetadataUseCase::new( + Arc::clone(&deps.files), + Arc::clone(&deps.metadata), + Arc::clone(&events), + )); + + // WHY clone: the same `Arc` lives inside + // `VolumeUseCase` (above) AND on the container field so shell + // sites that need `find_or_create` can reach it without a + // second open. Arc::clone is refcount-only; no allocation. + let volumes = Arc::clone(&deps.volumes); + // WHY same treatment for tags: CLI `tag ls` + `ls --tag` call + // `count_files_for_tag` / `files_with_tag` directly — not + // exposed by `TagUseCase`. Arc::clone is refcount-only. + let tags = Arc::clone(&deps.tags); + // WHY same treatment for metadata_repo: CLI `perima metadata + // ` clones the adapter into a background `MetadataQueue` + // worker; re-extraction is not exposed by `MetadataUseCase`. + // Arc::clone is refcount-only. + let metadata_repo = Arc::clone(&deps.metadata); + // WHY same treatment for files_repo: CLI `tag add/rm` + + // `metadata ` resolve a filesystem path → BlakeHash via + // `FileRepository::list_file_locations`. Not exposed by any + // UseCase. Arc::clone is refcount-only. + let files_repo = Arc::clone(&deps.files); + + Arc::new(Self { + scan, + search, + tag, + volume, + metadata, + events, + volumes, + tags, + metadata_repo, + files_repo, + }) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used)] // Test code; unwrap panics signal bugs. +mod tests { + use std::sync::Mutex; + use std::time::Duration; + + use perima_core::{AppEvent, FileEvent, MediaPath, VolumeId}; + use perima_db::{ + ReadPool, SqliteFileRepository, SqliteMetadataRepository, SqliteSearchRepository, + SqliteTagRepository, SqliteVolumeRepository, SqliteWriter, SqliteWriterHandle, + }; + use perima_fs::WalkdirScanner; + use perima_hash::Blake3Service; + use tempfile::TempDir; + + use super::*; + + /// Records every event it receives. Used to assert fan-out via the + /// new `Bus` + `EventHandler` wiring. Async `handle` matches the + /// post-Task-6 trait shape (Batch E §2.2). + struct RecordingHandler { + received: Arc>>, + } + + #[async_trait::async_trait] + impl EventHandler for RecordingHandler { + fn name(&self) -> &'static str { + "recording_handler" + } + + async fn handle(&mut self, event: AppEvent) { + self.received.lock().unwrap().push(event); + } + } + + /// Build an `AppEvent::File(FileEvent::Created)` for tests. + fn event() -> AppEvent { + AppEvent::File(FileEvent::Created { + path: MediaPath::new("test-fanout.bin"), + volume: VolumeId::new(), + }) + } + + /// Build `AppDeps` backed by real `SQLite` adapters on a fresh + /// temp DB. Matches the `harness()` pattern in `scan.rs` tests. + /// + /// WHY the writer handle is returned: tests must keep it alive so + /// the writer thread outlives the container's repository handles + /// (post-Batch-C Task 2 the volume adapter holds a sender tied to + /// this writer). + /// + /// WHY a fresh `Bus` is passed to `SqliteWriter::start`: the writer + /// needs an `Arc` to publish post-COMMIT events. In + /// tests we don't observe those events through this bus — the + /// container builds its own `Bus` internally — but the writer still + /// requires a live sink. A bare `Bus` with no subscribers acts as a + /// no-op (events are queued in the ring buffer but never consumed). + fn deps_harness() -> (TempDir, AppDeps, SqliteWriterHandle) { + let db_tmp = tempfile::tempdir().unwrap(); + let db_path = db_tmp.path().join("perima.db"); + + let writer_bus: Arc = Bus::new(); + let writer = SqliteWriter::start(&db_path, writer_bus).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + + let files: Arc = + Arc::new(SqliteFileRepository::new(writer.sender(), reads.clone())); + let volumes: Arc = + Arc::new(SqliteVolumeRepository::new(writer.sender(), reads.clone())); + let tags: Arc = + Arc::new(SqliteTagRepository::new(writer.sender(), reads.clone())); + let metadata: Arc = Arc::new(SqliteMetadataRepository::new( + writer.sender(), + reads.clone(), + )); + let search: Arc = + Arc::new(SqliteSearchRepository::new(writer.sender(), reads)); + let hasher: Arc = Arc::new(Blake3Service::new()); + let scanner: Arc = Arc::new(WalkdirScanner::new()); + let thumbnailer: Arc = Arc::new(ThumbnailGenerator::disabled()); + + ( + db_tmp, + AppDeps { + files, + volumes, + tags, + metadata, + search, + hasher, + scanner, + thumbnailer, + }, + writer, + ) + } + + #[tokio::test(flavor = "multi_thread")] + async fn app_container_new_builds_successfully_with_real_adapters() { + let (_db_tmp, deps, _writer) = deps_harness(); + let container = AppContainer::new(deps, vec![]); + + // Arc clone must be cheap; the inner struct is shared. + let c2 = Arc::clone(&container); + assert!(Arc::ptr_eq(&container, &c2)); + + // All five UseCases must be populated. + assert_eq!(Arc::strong_count(&container.scan), 1); + assert_eq!(Arc::strong_count(&container.search), 1); + assert_eq!(Arc::strong_count(&container.tag), 1); + assert_eq!(Arc::strong_count(&container.volume), 1); + assert_eq!(Arc::strong_count(&container.metadata), 1); + } + + #[tokio::test(flavor = "multi_thread")] + async fn app_container_shares_events_across_use_cases() { + // The container's `events` field is the single shared `Bus`; + // each UseCase receives an `Arc::clone` of it. After construction, + // the strong count on `container.events` reflects the shared + // ownership: 1 (container) + 5 (one per UseCase) = 6. + let (_db_tmp, deps, _writer) = deps_harness(); + + // Pass a recording handler so we can observe fan-out from the + // container's single shared bus when a UseCase emits. + let received = Arc::new(Mutex::new(Vec::::new())); + let handler: Box = Box::new(RecordingHandler { + received: Arc::clone(&received), + }); + let container = AppContainer::new(deps, vec![handler]); + + let events_strong = Arc::strong_count(&container.events); + assert_eq!( + events_strong, 6, + "container.events should be Arc-cloned once per UseCase plus the container field" + ); + + // Direct emit through the container's bus must reach every + // spawned handler task. The recv_loop is async, so yield long + // enough for the spawned task to drain the broadcast queue. + container.events.emit(&event()).unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(received.lock().unwrap().len(), 1); + } +} diff --git a/crates/app/src/events.rs b/crates/app/src/events.rs new file mode 100644 index 0000000..648c38a --- /dev/null +++ b/crates/app/src/events.rs @@ -0,0 +1,73 @@ +//! `EventHandler` trait + shared `recv_loop` helper. +//! +//! `AppContainer::new` (in `crates/app/src/container.rs`) spawns one +//! tokio task per registered handler, each running `recv_loop` against +//! its own `Receiver` from the bus. + +use async_broadcast::{Receiver, RecvError}; +use futures::future::FutureExt; +use perima_core::AppEvent; + +/// Long-lived consumer of [`AppEvent`]s broadcast by the bus. +/// +/// Implementors are spawned as tokio tasks by `AppContainer::new`. +/// Each task owns its own `Receiver` cursor into the shared ring buffer +/// (currently 256 capacity — see [`crate::bus`]). +/// +/// **Performance contract.** `handle` should be fast (typical: +/// microseconds for log, milliseconds for DB write or Tauri emit). +/// A handler that blocks for >100ms per event risks filling its inbox +/// under burst load — the next `recv()` returns `Overflowed(n)` and +/// the loop logs a warning. Capacity 256 means a sustained 100ms +/// handler can absorb a 25-second burst. +#[async_trait::async_trait] +pub trait EventHandler: Send + 'static { + /// Stable identifier for telemetry / logging. Convention: `snake_case` + /// matching the impl struct (e.g. `"log_event_handler"`). + fn name(&self) -> &'static str; + + /// Process one event. Panics inside this function are caught by + /// `recv_loop` and logged; the loop continues. + async fn handle(&mut self, event: AppEvent); +} + +/// Run the recv loop for a single handler. Spawned by +/// [`crate::container::AppContainer::new`] (Batch E Task 6). +/// +/// Exits when the bus closes (all senders dropped — typically +/// container shutdown). On `Overflowed(n)` from the receiver, logs + +/// continues. On panic inside `handle`, logs + continues to next event. +pub(crate) async fn recv_loop( + name: &'static str, + mut handler: Box, + mut recv: Receiver, +) { + loop { + match recv.recv().await { + Ok(event) => { + // WHY catch_unwind via FutureExt: a panic inside `handle` + // should not kill the recv loop. AssertUnwindSafe is + // required because `&mut handler` is not UnwindSafe by + // default; we manually assert that handler state recovery + // is the impl's responsibility. + let result = std::panic::AssertUnwindSafe(handler.handle(event)) + .catch_unwind() + .await; + if let Err(panic) = result { + tracing::error!( + handler = name, + panic = ?panic, + "event handler panicked; loop continues" + ); + } + } + Err(RecvError::Overflowed(n)) => { + tracing::warn!(handler = name, missed = n, "broadcast lag; events dropped"); + } + Err(RecvError::Closed) => { + tracing::info!(handler = name, "bus closed; recv loop exiting"); + return; + } + } + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs new file mode 100644 index 0000000..27e7298 --- /dev/null +++ b/crates/app/src/lib.rs @@ -0,0 +1,49 @@ +//! perima-app — application-service layer. +//! +//! Six concrete `UseCase` structs orchestrate across the `perima-core` +//! ports. Each exposes a single `async fn execute(&self, cmd: Cmd) -> +//! Result`. Zero generic parameters; dependency ports +//! carried as `Arc` fields. CLI and Desktop shells consume +//! the `AppContainer` built from an `AppDeps`. +//! +//! # Why this shape +//! +//! Audit §4.1: mature Rust codebases (zed, rust-analyzer, atuin, +//! crates.io) use concrete orchestrator structs with `Arc` +//! fields, not trait-object soup or `struct UseCase`. +//! LLM-authoring sessions reproduce this shape with the highest +//! fidelity. +//! +//! # Watch deferral +//! +//! `Watch` is intentionally NOT a `UseCase` in v1. The long-running + +//! cancellation-handle shape doesn't fit `async fn execute(&self, cmd) +//! -> Result`. Watch stays in `crates/cli/src/cmd/ +//! watch.rs` + `crates/desktop/src/commands.rs::{start_watch, +//! stop_watch, is_watching}` until a dedicated design lands (see +//! follow-up GH issue filed during Batch B). + +#![forbid(unsafe_code)] + +pub mod bus; +pub mod container; +pub mod events; +pub mod metadata; +pub mod scan; +pub mod search; +pub mod tag; +pub mod telemetry; +pub mod volume; + +pub use bus::Bus; +pub use container::{AppContainer, AppDeps}; +pub use events::EventHandler; +pub use metadata::{MetadataCommand, MetadataOutput, MetadataUseCase}; +pub use scan::{ + FullScan, METADATA_DRAIN_TIMEOUT, OnPersist, ScanCommand, ScanReport, ScanReportEntry, + ScanUseCase, +}; +pub use search::{SearchCommand, SearchOutput, SearchUseCase}; +pub use tag::{FileWithTags, TagCommand, TagFilter, TagOutput, TagUseCase}; +pub use telemetry::LogEventHandler; +pub use volume::{VolumeCommand, VolumeOutput, VolumeUseCase}; diff --git a/crates/app/src/metadata.rs b/crates/app/src/metadata.rs new file mode 100644 index 0000000..7b8a0e9 --- /dev/null +++ b/crates/app/src/metadata.rs @@ -0,0 +1,429 @@ +//! `MetadataUseCase` — orchestrates file-location listing and metadata join. +//! +//! This is the `crates/app` port of the file-listing orchestration that +//! previously lived in `crates/cli/src/cmd/ls.rs` (`run`, calling +//! `FileRepository::list_file_locations` + `MetadataRepository::list_with_metadata`) +//! and `crates/desktop/src/commands.rs::{list_files_inner, +//! list_files_with_metadata_inner}`. +//! +//! Zero generics: dependency ports are carried as `Arc` fields; +//! a single `async fn execute(&self, cmd: MetadataCommand) -> +//! Result` exposes both listing operations. +//! +//! # Why two commands (not two use-cases) +//! +//! `ListFiles` (file-locations only) and `ListFilesWithMetadata` +//! (LEFT JOIN with `file_metadata`) share the same `FileRepository` + +//! `MetadataRepository` dependency set. Splitting them into two +//! use-cases would force callers to wire two structs for what is +//! conceptually one "list files" surface. Two commands in one +//! `UseCase` is the right grain — distinct operations, shared deps. +//! +//! # `DeviceId` on commands +//! +//! Both commands carry a `device: DeviceId` field following the +//! `TagCommand::Attach/Detach` + `VolumeCommand::RecordMount` pattern: +//! callers know their device context at call-site, not at construction +//! time. v1 uses the field for future CRDT scoping; it is forwarded to +//! repo calls where applicable. +//! +//! # Pagination defaults +//! +//! When `limit` is `None`, a default of **100** rows is applied — matching +//! the CLI's `--limit` `default_value` of 100 (verified in the `ls` arg +//! parser). `offset` defaults to 0. +//! +//! See [`MetadataUseCase::execute`] for the workflow body. + +use std::sync::Arc; + +use perima_core::{ + CoreError, DeviceId, EventBus, FileLocationRecord, FileRepository, MediaMetadata, + MetadataRepository, +}; + +/// Default page size when `limit` is `None`. +/// +/// WHY 100: matches the CLI's `--limit` `default_value` in +/// `crates/cli/src/cmd/ls.rs` (`LsArgs::limit` is usize, CLI sets 100). +pub(crate) const DEFAULT_LIMIT: u32 = 100; + +/// Inputs to [`MetadataUseCase::execute`]. +#[derive(Debug, Clone)] +pub enum MetadataCommand { + /// List file-location records for `device`, up to `limit` rows + /// starting at `offset`. + /// + /// WHY per-command device: same rationale as `VolumeCommand::List` — + /// the caller knows the machine context; the `UseCase` struct is + /// shared across machines in multi-device CLI scenarios. + ListFiles { + /// Max rows to return; `None` applies `DEFAULT_LIMIT` (100). + limit: Option, + /// Row offset for pagination; `None` applies 0. + offset: Option, + /// Device (machine) requesting the listing. + device: DeviceId, + }, + + /// List file-location records left-joined with `file_metadata`. + /// + /// Locations without a metadata row appear with `metadata: None`; + /// callers should treat that as "pending extraction", not "absent". + ListFilesWithMetadata { + /// Max rows to return; `None` applies `DEFAULT_LIMIT` (100). + limit: Option, + /// Row offset for pagination; `None` applies 0. + offset: Option, + /// Device (machine) requesting the listing. + device: DeviceId, + }, +} + +/// Output of a successful metadata operation. +#[derive(Debug, Clone)] +pub enum MetadataOutput { + /// Response to [`MetadataCommand::ListFiles`] — plain location records. + Files(Vec), + + /// Response to [`MetadataCommand::ListFilesWithMetadata`] — each record + /// paired with its optional metadata. + /// + /// `None` metadata means extraction is pending or failed; callers + /// MUST NOT treat `None` as "file has no metadata" for display + /// purposes — "pending" is the correct label. + FilesWithMetadata(Vec<(FileLocationRecord, Option)>), +} + +/// Orchestrator: file-location listing and metadata join. +/// +/// Dependencies are carried as `Arc` fields; there are zero +/// generic parameters on the struct itself. See +/// [`MetadataUseCase::execute`] for the workflow body. +pub struct MetadataUseCase { + files: Arc, + metadata: Arc, + // WHY `events` is held but unused in the orchestration body today: + // Batch E will emit `FileEvent::Listed` (or similar) once the + // async-broadcast bus lands. Holding the handle at construction makes + // the Batch-E diff a single-file addition rather than a signature + // churn across every caller. The field is silenced with a + // `_ = &self.events` one-liner (preferred zero-cost form). + events: Arc, +} + +impl std::fmt::Debug for MetadataUseCase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MetadataUseCase").finish_non_exhaustive() + } +} + +impl MetadataUseCase { + /// Construct a `MetadataUseCase` with the given dependency ports. + /// + /// The container (Task 7) calls this once and shares the resulting + /// `Arc` across surfaces. + #[must_use] + pub fn new( + files: Arc, + metadata: Arc, + events: Arc, + ) -> Self { + Self { + files, + metadata, + events, + } + } + + /// Execute the metadata command. + /// + /// # Errors + /// - [`CoreError::Internal`] on `SQLite` failures from the repository. + // WHY allow unused_async: `FileRepository` + `MetadataRepository` + // methods are synchronous today; the `async fn` signature is + // mandated by the UseCase contract so the Batch-C connection-actor + // swap (async write channel) can evolve the impl without touching + // callers. Removing `async` now would force caller-side churn when + // the trait gains async variants. + #[allow(clippy::unused_async)] + pub async fn execute(&self, cmd: MetadataCommand) -> Result { + // WHY touch self.events: held for the Batch-E event-emit path; + // reference the field so `unused` lints don't fire before + // Batch E wires the emissions. + let _ = &self.events; + + match cmd { + MetadataCommand::ListFiles { + limit, + offset: _, + device: _, + } => { + // WHY offset ignored today: `FileRepository::list_file_locations` + // takes only `(limit, volume)`. Offset-based pagination is a + // Batch C / post-actor concern (see GH issue for cursor + // pagination). The parameter is accepted in the command so the + // API surface is stable before the underlying repo gains cursor + // support — callers won't need a signature churn. + let effective_limit = limit.unwrap_or(DEFAULT_LIMIT) as usize; + let records = self.files.list_file_locations(effective_limit, None)?; + Ok(MetadataOutput::Files(records)) + } + + MetadataCommand::ListFilesWithMetadata { + limit, + offset: _, + device: _, + } => { + // WHY offset ignored: same as ListFiles arm above. + let effective_limit = limit.unwrap_or(DEFAULT_LIMIT) as usize; + let rows = self.metadata.list_with_metadata(effective_limit, None)?; + Ok(MetadataOutput::FilesWithMetadata(rows)) + } + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] // Test code; unwrap panics signal bugs. +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use perima_core::{ + AppEvent, BlakeHash, CoreError, DeviceId, DiscoveredFile, EventBus, FileSize, HashedFile, + MediaMetadata, MediaPath, VolumeId, VolumeIdentifiers, VolumeRepository, + }; + use perima_db::{ + ReadPool, SqliteFileRepository, SqliteMetadataRepository, SqliteVolumeRepository, + SqliteWriter, SqliteWriterHandle, + }; + use tempfile::TempDir; + + use super::*; + + /// No-op event bus for tests that don't care about emissions. + struct NullBus; + impl EventBus for NullBus { + fn emit(&self, _event: &AppEvent) -> Result<(), CoreError> { + Ok(()) + } + } + + /// Build a [`MetadataUseCase`] backed by a real `SQLite` DB in a tempdir. + /// + /// WHY single harness: every test uses this helper so setup is + /// consistent and the `TempDir` lifetime is managed uniformly. + /// Returns the use-case, the file repo (for seeding), the metadata + /// repo (for seeding), the tempdir guard, and the writer handle + /// (tests keep it alive so the writer thread outlives the adapter + /// senders — post-Batch-C Task 4 the metadata adapter holds a + /// sender tied to this writer). + fn harness() -> ( + MetadataUseCase, + Arc, + Arc, + TempDir, + SqliteWriterHandle, + ) { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("perima.db"); + let events: Arc = Arc::new(NullBus); + let writer = SqliteWriter::start(&db_path, Arc::clone(&events)).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let files: Arc = + Arc::new(SqliteFileRepository::new(writer.sender(), reads.clone())); + let metadata: Arc = + Arc::new(SqliteMetadataRepository::new(writer.sender(), reads)); + let uc = MetadataUseCase::new( + Arc::clone(&files) as Arc, + Arc::clone(&metadata) as Arc, + events, + ); + (uc, files, metadata, tmp, writer) + } + + fn device() -> DeviceId { + DeviceId::new() + } + + /// Seed a single file + location into the file repository. + /// + /// WHY helper: repeated boilerplate for constructing a `HashedFile` + + /// `upsert_file` + `upsert_location` across every test is noise that + /// obscures the assertion intent. + /// + /// WHY two distinct hash bytes per call site: `BlakeHash::from_bytes` + /// accepts any 32-byte array; using a counter-derived constant ensures + /// two files in the same test don't collide on the content-addressed + /// `files` table. The caller passes a discriminant byte. + fn seed_file( + repo: &SqliteFileRepository, + dev: DeviceId, + volume_id: VolumeId, + rel_path: &str, + hash_byte: u8, + ) -> BlakeHash { + use perima_core::{FileRepository, UpsertOutcome}; + + let hash = BlakeHash::from_bytes([hash_byte; 32]); + let file = HashedFile { + discovered: DiscoveredFile { + absolute_path: PathBuf::from(format!("/tmp/fake/{rel_path}")), + relative_path: MediaPath::new(rel_path), + size: FileSize(1024), + }, + hash, + }; + let outcome = repo.upsert_file(&file, dev).unwrap(); + assert!( + matches!(outcome, UpsertOutcome::Inserted | UpsertOutcome::Updated), + "upsert_file should succeed" + ); + repo.upsert_location(&hash, volume_id, &MediaPath::new(rel_path), dev) + .unwrap(); + hash + } + + /// Create a volume id for testing (using volume repo via writer+pool). + /// + /// WHY a self-contained writer here: these metadata tests don't + /// otherwise need a volume adapter in their shared harness, and the + /// seed path is a one-shot. The writer thread is dropped when + /// `handle.join()` runs at scope end — tempfile-backed DB stays + /// alive via the caller's `TempDir`. + fn seed_volume(db_path: &std::path::Path, dev: DeviceId) -> VolumeId { + struct NoopBus; + impl EventBus for NoopBus { + fn emit(&self, _: &AppEvent) -> Result<(), CoreError> { + Ok(()) + } + } + let writer = SqliteWriter::start(db_path, Arc::new(NoopBus)).unwrap(); + let reads = ReadPool::open(db_path).unwrap(); + let vol_repo = SqliteVolumeRepository::new(writer.sender(), reads); + let ident = VolumeIdentifiers { + gpt_partition_guid: None, + fs_uuid: Some("test-uuid-meta".to_owned()), + label: Some("MetaTestVol".to_owned()), + capacity_bytes: 1_000_000, + is_removable: false, + }; + let out = vol_repo.find_or_create(&ident, dev).unwrap(); + drop(vol_repo); + writer.join(); + out + } + + // ----------------------------------------------------------------------- + // ListFiles + // ----------------------------------------------------------------------- + + /// `ListFiles` returns seeded file-location records. + #[tokio::test] + async fn list_files_returns_seeded_records() { + let (uc, file_repo, _meta_repo, tmp, _writer) = harness(); + let dev = device(); + let vol_id = seed_volume(tmp.path().join("perima.db").as_path(), dev); + + seed_file(&file_repo, dev, vol_id, "photos/a.jpg", 0xab); + + let out = uc + .execute(MetadataCommand::ListFiles { + limit: None, + offset: None, + device: dev, + }) + .await + .unwrap(); + + let MetadataOutput::Files(records) = out else { + panic!("expected MetadataOutput::Files"); + }; + assert!( + !records.is_empty(), + "expected at least one file after seeding" + ); + assert_eq!( + records[0].relative_path.as_str(), + "photos/a.jpg", + "seeded path should appear in listing" + ); + } + + // ----------------------------------------------------------------------- + // ListFilesWithMetadata + // ----------------------------------------------------------------------- + + /// `ListFilesWithMetadata` left-joins correctly: + /// - Files with a seeded metadata row appear as `(record, Some(meta))`. + /// - Files without a metadata row appear as `(record, None)`. + #[tokio::test] + async fn list_files_with_metadata_left_joins() { + use perima_core::MetadataRepository; + + let (uc, file_repo, meta_repo, tmp, _writer) = harness(); + let dev = device(); + let vol_id = seed_volume(tmp.path().join("perima.db").as_path(), dev); + + // Seed two files with distinct hash bytes so content-addressed rows don't collide. + let hash_with_meta = seed_file(&file_repo, dev, vol_id, "media/has_meta.jpg", 0xaa); + let _hash_no_meta = seed_file(&file_repo, dev, vol_id, "media/no_meta.jpg", 0xbb); + + // Only seed metadata for the first file. + let meta_row = MediaMetadata { + hash: hash_with_meta, + mime_type: Some("image/jpeg".to_owned()), + width: Some(1920), + height: Some(1080), + duration_ms: None, + captured_at: Some("2024-01-15T10:00:00Z".to_owned()), + camera_make: Some("Canon".to_owned()), + camera_model: Some("EOS R5".to_owned()), + codec: None, + bitrate_bps: None, + thumbnail_path: None, + thumbnail_status: None, + }; + meta_repo.upsert_metadata(&meta_row, dev).unwrap(); + + let out = uc + .execute(MetadataCommand::ListFilesWithMetadata { + limit: None, + offset: None, + device: dev, + }) + .await + .unwrap(); + + let MetadataOutput::FilesWithMetadata(rows) = out else { + panic!("expected MetadataOutput::FilesWithMetadata"); + }; + + assert_eq!(rows.len(), 2, "both files should appear in the listing"); + + // Find each row by path. + let with_meta = rows + .iter() + .find(|(r, _)| r.relative_path.as_str() == "media/has_meta.jpg") + .expect("file with metadata should appear"); + let without_meta = rows + .iter() + .find(|(r, _)| r.relative_path.as_str() == "media/no_meta.jpg") + .expect("file without metadata should appear"); + + assert!( + with_meta.1.is_some(), + "file with seeded metadata should have Some(meta)" + ); + assert_eq!( + with_meta.1.as_ref().unwrap().camera_model.as_deref(), + Some("EOS R5"), + "camera_model should match seeded value" + ); + assert!( + without_meta.1.is_none(), + "file without seeded metadata should have None" + ); + } +} diff --git a/crates/app/src/scan.rs b/crates/app/src/scan.rs new file mode 100644 index 0000000..bd8bfb1 --- /dev/null +++ b/crates/app/src/scan.rs @@ -0,0 +1,905 @@ +//! `ScanUseCase` — orchestrates volume scanning across walker, hasher, +//! repositories, and the metadata queue. +//! +//! This is the `crates/app` port of the orchestration that previously +//! lived in `crates/cli/src/cmd/scan.rs::run` (≈450 LOC) +//! and the parallel desktop helpers. Zero generics: dependency ports +//! are carried as `Arc` fields; a single +//! `async fn execute(&self, cmd: ScanCommand) -> Result` exposes the workflow. +//! +//! See [`ScanUseCase::execute`] for the workflow body. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use serde::Serialize; + +use perima_core::{ + BlakeHash, CoreError, DeviceId, DiscoveredFile, EventBus, FileRepository, HashService, + HashedFile, MediaPath, MetadataExtractor, MetadataRepository, Scanner, UpsertOutcome, VolumeId, + VolumeRepository, +}; +use perima_media::{ + CompositeExtractor, ImageExtractor, MetadataQueue, ThumbnailGenerator, VideoExtractor, +}; +use rayon::prelude::*; +use tokio_util::sync::CancellationToken; + +/// Maximum time `execute` waits for the metadata worker to drain after +/// the walk loop completes. +/// +/// WHY 30 s: long enough for the typical <10-file corpus the integration +/// tests use to complete comfortably, short enough that Ctrl-C remains +/// responsive (the drain also polls cancel). `no_wait_metadata` bypasses +/// this when the caller wants a fast scan exit. +pub const METADATA_DRAIN_TIMEOUT: Duration = Duration::from_secs(30); + +/// Optional callback invoked after a successful `upsert_location`. +/// +/// Signature: `(relative_path, real_volume_id, device_id)`. +/// +/// WHY this survives as a command-level hook (vs. a `FileEvent`): the +/// current production caller (CLI + Desktop) wires this to +/// `perima_db::SqliteFileRepository::migrate_sentinel_row` — an +/// adapter-specific method that is NOT on the `FileRepository` trait. +/// Lifting it into a `FileEvent` variant would force every `EventBus` +/// impl to handle a new event shape and breaks the Batch-B "no public +/// API break in crates/core" constraint. When that trade-off becomes +/// worthwhile, add a `FileEvent::LocationUpserted` variant and an +/// `EventHandler` impl in the `db` adapter — the post-Batch-E `Bus` +/// makes that additive and cheap. +/// +/// WHY `Arc` (not `&dyn Fn`): `ScanCommand` is +/// `Clone` + passed into tokio tasks in some callers; `&dyn Fn` would +/// pin the command to a single stack frame and trip borrow-checker +/// errors the moment anyone moved it into `tokio::spawn`. +pub type OnPersist = Arc; + +/// Inputs to [`ScanUseCase::execute`]. +#[derive(Clone)] +pub enum ScanCommand { + /// Full scan of a root path. + Full(FullScan), + /// Re-scan — incremental update at the same root. + /// + /// WHY delegates to `Full` internally: the current orchestration + /// body has no dedicated "rescan" branch — idempotence comes from + /// the `INSERT OR REPLACE` semantics of the `upsert_*` methods. + /// A user-visible rescan differs from a scan only in defaults: + /// no metadata, no dry-run, no on-persist sentinel migration + /// (the sentinel row is already resolved by the first scan). + /// Collapsing to `Full { with_metadata: false, dry_run: false, + /// on_persist: None, ... }` preserves behavior until the CRDT + /// rescan story lands (post-v1). + Rescan { + /// Root directory to walk. + path: PathBuf, + /// Device performing the scan. + device_id: DeviceId, + /// Cancellation token. Test callers pass a fresh `CancellationToken::new()`. + cancel: CancellationToken, + }, +} + +impl std::fmt::Debug for ScanCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Full(inner) => f.debug_tuple("Full").field(inner).finish(), + Self::Rescan { + path, device_id, .. + } => f + .debug_struct("Rescan") + .field("path", path) + .field("device_id", device_id) + .finish_non_exhaustive(), + } + } +} + +/// Payload for [`ScanCommand::Full`]. +/// +// WHY allow struct_excessive_bools: each flag corresponds to a distinct +// user-facing CLI flag on `perima scan` (or a Desktop UI toggle). The +// flags are orthogonal (dry-run vs no-wait-metadata vs no-thumbnails) +// and collapsing them into a typed enum would either fuse axes or +// bloat the CLI surface. Matches the `ScanArgs` pattern in +// `crates/cli/src/cmd/scan.rs`. +#[allow(clippy::struct_excessive_bools)] +#[derive(Clone)] +pub struct FullScan { + /// Root directory to walk. + pub path: PathBuf, + /// Device performing the scan. + pub device_id: DeviceId, + /// When true, spawn the metadata queue + extract per file. + pub with_metadata: bool, + /// When true, hash + summarize but skip every DB write and volume + /// detection. `file_repo` and `volume_repo` are not read. + pub dry_run: bool, + /// When true, skip the bounded post-walk drain of the metadata queue. + /// + /// WHY opt-in: by default `execute` waits up to + /// [`METADATA_DRAIN_TIMEOUT`] for in-flight metadata extraction + /// to persist. For very large scans where the caller would rather + /// return immediately and let the queue die with the runtime, + /// setting this true bypasses the drain. + pub no_wait_metadata: bool, + /// Disable WebP thumbnail generation for image/video files. + /// + /// WHY opt-in: thumbnails double the per-file work (decode + encode + /// vs header-only read). Callers that want metadata-only indexing + /// set this; rows stay at `thumbnail_status = 'pending'` so a + /// future retry can generate them later. Regardless of the + /// `ScanUseCase::thumbnailer` field supplied at construction, + /// this flag forces `ThumbnailGenerator::disabled()` internally. + pub no_thumbnails: bool, + /// Cancellation token. Test callers pass a fresh + /// `CancellationToken::new()` and never cancel. + pub cancel: CancellationToken, + /// Optional callback invoked after each successful location upsert. + /// See [`OnPersist`] for the intended use (sentinel-row migration) + /// and the WHY this is a command-level hook rather than a + /// `FileEvent` variant. + pub on_persist: Option, +} + +impl std::fmt::Debug for FullScan { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FullScan") + .field("path", &self.path) + .field("device_id", &self.device_id) + .field("with_metadata", &self.with_metadata) + .field("dry_run", &self.dry_run) + .field("no_wait_metadata", &self.no_wait_metadata) + .field("no_thumbnails", &self.no_thumbnails) + .field("on_persist", &self.on_persist.as_ref().map(|_| "")) + .finish_non_exhaustive() + } +} + +/// One row of per-file scan output, surfaced to shells that want to +/// print hash/size/path lines (CLI `perima scan` default behaviour). +/// +/// WHY exposed on `ScanReport` instead of emitted via `FileEvent`: +/// see the [`OnPersist`] WHY — adding a new `FileEvent` variant in +/// Batch B would cascade through every `EventBus` impl; Batch E's +/// bus-engine swap is the right place for that change. The shell +/// iterates `per_file_entries` post-execute and prints its own lines. +/// +/// WHY `Serialize`: the desktop `scan` handler returns `ScanReport` +/// directly across the IPC boundary (Batch D Task 8); per-file entries +/// are skipped at the serde boundary (`#[serde(skip)]`) because the +/// frontend only needs aggregate stats. CLI shells access the field +/// directly without serde. +#[derive(Debug, Clone, Serialize)] +pub struct ScanReportEntry { + /// Content hash of the file as hashed this run. + pub hash: BlakeHash, + /// File size in bytes at walk time. + pub size: u64, + /// Relative path within the volume. + pub relative_path: MediaPath, +} + +/// Output of a successful scan. +/// +/// WHY `Serialize + specta::Type`: the desktop `scan` handler returns this +/// struct directly across the Tauri IPC boundary (Batch D Task 8). +/// Shell-internal fields (`per_file_entries`, `manifest_files`, +/// `volume_mount`) are marked `#[serde(skip)]` because the frontend +/// only needs aggregate stats; CLI shells access those fields in +/// Rust after `execute` returns. +#[derive(Debug, Clone, Default, Serialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +pub struct ScanReport { + /// Total files walked + attempted to hash. + pub files_seen: u64, + /// Files newly inserted into `files` / `file_locations`. + pub files_new: u64, + /// Files present on a prior scan (Updated or Unchanged outcome). + pub files_updated: u64, + /// Files that errored during hash or persist. + pub files_errored: u64, + /// Total bytes hashed. + pub bytes_hashed: u64, + /// Wall-clock duration of the scan. + pub duration_ms: u64, + /// True if cancellation was signalled during the run. + pub interrupted: bool, + /// Volume label (from `VolumeIdentifiers::label`) once detected. + pub volume_label: Option, + /// Volume id + mount point assigned this run. `None` in dry-run. + /// + /// WHY surfaced: the shell needs this to call + /// `perima_db::manifest::write_manifest` after `execute` + /// returns; `crates/app` deliberately does NOT depend on + /// `perima-db` (spec §2 IN), so the link is plain-text rather + /// than an intra-doc reference. + /// + /// WHY `#[serde(skip)]`: this is a shell-internal routing value + /// (used by CLI + desktop to call `write_manifest`). The frontend + /// has no use for a raw `(VolumeId, PathBuf)` tuple. Aggregate + /// stats (`files_new`, etc.) are what the UI consumes. + #[serde(skip)] + pub volume_mount: Option<(VolumeId, PathBuf)>, + /// Per-file details for shells that print a hash/size/path line. + /// Empty for callers that only consume aggregate stats. + /// + /// WHY `#[serde(skip)]`: shell-internal; the frontend has no use + /// for a per-file entry list on scan completion. CLI access is + /// direct (no serde). Future UI needs (Batch H) would define a + /// dedicated IPC event stream, not a bulk payload. + #[serde(skip)] + pub per_file_entries: Vec, + /// Hashed files that were successfully persisted this run; passed + /// by the shell to `perima_db::manifest::write_manifest` to create + /// `.perima/manifest.db` at the volume root. + /// + /// WHY `#[serde(skip)]`: see `volume_mount` — manifest writing is + /// shell-side plumbing; the frontend never inspects this list. + #[serde(skip)] + pub manifest_files: Vec, +} + +/// Orchestrator: walk → hash → persist → (optionally) extract metadata. +/// +/// Dependencies are carried as `Arc` fields; there are zero +/// generic parameters on the struct itself. See [`ScanUseCase::execute`] +/// for the workflow body. +pub struct ScanUseCase { + files: Arc, + volumes: Arc, + metadata: Arc, + scanner: Arc, + hasher: Arc, + thumbnailer: Arc, + // WHY `events` is held but unused in the orchestration body today: + // Batch E will emit `FileEvent::{Created, Modified}` from here + // once the bus-engine swap lands. Holding the handle at + // construction makes the Batch-E diff a single-file addition + // rather than a signature churn across every caller. The field + // goes through a `_events` mention below to quiet dead-code lints. + events: Arc, +} + +impl std::fmt::Debug for ScanUseCase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ScanUseCase").finish_non_exhaustive() + } +} + +impl ScanUseCase { + /// Construct a `ScanUseCase` with the given dependency ports. + /// + /// The container (Task 7) calls this once and shares the resulting + /// `Arc` across surfaces. + #[must_use] + pub fn new( + files: Arc, + volumes: Arc, + metadata: Arc, + scanner: Arc, + hasher: Arc, + thumbnailer: Arc, + events: Arc, + ) -> Self { + Self { + files, + volumes, + metadata, + scanner, + hasher, + thumbnailer, + events, + } + } + + /// Execute the scan command. + /// + /// # Errors + /// - `CoreError::InvalidPath` if the root does not exist or is not + /// a directory. + /// - `CoreError::Io` from the canonicalization + walk path. + /// - Propagates `CoreError` from the scanner, hasher, volume + /// detection, and repository adapters. + pub async fn execute(&self, cmd: ScanCommand) -> Result { + match cmd { + ScanCommand::Full(full) => self.execute_full(full).await, + ScanCommand::Rescan { + path, + device_id, + cancel, + } => { + // Rescan delegates to Full with defaults that match a + // non-dry, metadata-free, sentinel-migration-free + // re-walk. See `ScanCommand::Rescan` doc for WHY. + self.execute_full(FullScan { + path, + device_id, + with_metadata: false, + dry_run: false, + no_wait_metadata: true, + no_thumbnails: true, + cancel, + on_persist: None, + }) + .await + } + } + } + + // WHY `#[allow(clippy::too_many_lines)]` + `cognitive_complexity`: + // this is a faithful port of the pre-Batch-B + // `crates/cli/src/cmd/scan.rs::run` body. Splitting the loop into + // helpers would require threading half a dozen borrowed locals + // (stats, queue, volume_info, manifest_files, cancel_token, entries) + // through helper signatures — worse readability for a lint that + // flags a body this project has shipped and hardened for several + // releases. The extraction into `crates/app` is itself the + // refactoring goal of Batch B; further sub-extraction is a + // Batch-I observability concern (tracing spans per phase). + #[allow(clippy::too_many_lines)] + #[allow(clippy::cognitive_complexity)] + async fn execute_full(&self, full: FullScan) -> Result { + let start = Instant::now(); + validate_root(&full.path)?; + + // WHY canonicalize once: on macOS, tempdir() returns + // /var/folders/... which is a symlink to /private/var/...; + // without canonicalizing, walkdir produces paths under /var/ + // that fail strip_prefix against /private/var/. + let canonical_root = canonicalize_for_walk(&full.path)?; + + let FullScan { + device_id, + with_metadata, + dry_run, + no_wait_metadata, + no_thumbnails, + cancel, + on_persist, + .. + } = full; + + // Effective thumbnailer: `no_thumbnails` forces `disabled()` + // regardless of the field the container wired in. + let effective_thumbnailer: Arc = if no_thumbnails { + Arc::new(ThumbnailGenerator::disabled()) + } else { + Arc::clone(&self.thumbnailer) + }; + + // Spawn the metadata queue up front (non-dry-run, with-metadata + // only). WHY at the top: the worker should be alive before the + // first `upsert_file` so the very first enqueue never races + // `tokio::spawn`. + let mut queue: Option = if with_metadata && !dry_run { + let extractor: Arc = Arc::new(CompositeExtractor::new(vec![ + Arc::new(ImageExtractor::new()) as Arc, + Arc::new(VideoExtractor::new()) as Arc, + ])); + Some(MetadataQueue::spawn( + extractor, + Arc::clone(&self.metadata), + Arc::clone(&effective_thumbnailer), + device_id, + cancel.clone(), + )) + } else { + None + }; + + // Resolve volume once before the scan loop (no-op in dry-run). + // WHY outside the per-file loop: the volume-repo lock is not + // held across rayon's parallel hash phase. + let volume_info: Option<(VolumeId, String, PathBuf)> = if dry_run { + None + } else { + let detected = perima_fs::detect_volume(&canonical_root)?; + let label = detected + .identifiers + .label + .clone() + .unwrap_or_else(|| "unknown".to_owned()); + let vol_id = self + .volumes + .find_or_create(&detected.identifiers, device_id)?; + self.volumes + .record_mount(vol_id, device_id, &detected.mount_point)?; + Some((vol_id, label, detected.mount_point)) + }; + + // Collect up-front so rayon can parallelize hashing; the walker + // iterator itself isn't Send across the par_iter boundary. The + // inner `take_while` polls between yielded items so a Ctrl-C + // during walk short-circuits quickly. + let discovered: Vec = self + .scanner + .walk(&canonical_root, &canonical_root)? + .take_while(|_| !cancel.is_cancelled()) + .collect(); + + // Parallel hash. WHY cancellation check at the top of each map + // closure: in-flight hashes short-circuit the moment Ctrl-C + // lands — without this, a large fixture would drain the + // par_iter to completion even after the flag flips, defeating + // the "Ctrl-C stops hashing" guarantee. + let cancel_token = cancel.clone(); + let hasher = Arc::clone(&self.hasher); + let results: Vec> = discovered + .into_par_iter() + .map(|d| { + if cancel_token.is_cancelled() { + return Err(CoreError::Internal("cancelled".into())); + } + let h = hasher.full_hash(&d.absolute_path)?; + Ok((d, h)) + }) + .collect(); + + let mut report = ScanReport { + volume_label: volume_info.as_ref().map(|(_, label, _)| label.clone()), + volume_mount: volume_info + .as_ref() + .map(|(v, _, mount)| (*v, mount.clone())), + ..Default::default() + }; + + for res in results { + report.files_seen += 1; + match res { + Ok((d, h)) => { + report.bytes_hashed += d.size.0; + report.per_file_entries.push(ScanReportEntry { + hash: h, + size: d.size.0, + relative_path: d.relative_path.clone(), + }); + if dry_run { + // Dry-run: count every successfully hashed file + // as new so the summary total is accurate. + report.files_new += 1; + continue; + } + let volume = volume_info + .as_ref() + .map_or_else(|| VolumeId(uuid::Uuid::nil()), |(v, _, _)| *v); + match persist_file(&*self.files, &d, &h, device_id, volume) { + Ok(outcome) => { + // WHY: sentinel migration runs per-file, + // scoped to (relative_path, sentinel + // volume_id, deleted_at IS NULL). Running + // right after a successful upsert confirms + // the file still exists on disk before we + // reattribute its old row to the real + // volume. See `OnPersist` for the scope + // rationale (Batch B keeps this as a + // command hook; Batch E moves it behind + // a `FileEvent` variant). + if let Some(cb) = on_persist.as_ref() { + cb(&d.relative_path, volume, device_id); + } + // WHY enqueue only on Inserted|Updated + // (not Unchanged): Unchanged means the + // scanner already persisted this hash with + // identical metadata on a prior scan — + // re-extracting would do identical work. + if matches!(outcome, UpsertOutcome::Inserted | UpsertOutcome::Updated) + && let Some(q) = queue.as_ref() + && let Err(e) = q.enqueue(h, d.absolute_path.clone(), &cancel) + { + // WHY log + continue: a metadata-queue + // failure must not abort the scan. The + // caller can re-run or invoke + // `perima metadata` for stragglers. + tracing::warn!( + error = %e, + path = %d.absolute_path.display(), + "metadata enqueue failed; continuing scan", + ); + } + report.manifest_files.push(HashedFile { + discovered: d, + hash: h, + }); + match outcome { + UpsertOutcome::Inserted => report.files_new += 1, + UpsertOutcome::Updated | UpsertOutcome::Unchanged => { + report.files_updated += 1; + } + } + } + Err(e) => { + tracing::warn!(error = %e, "persist failed"); + report.files_errored += 1; + } + } + } + Err(e) => { + tracing::warn!(error = %e, "skipping file: hash failed"); + report.files_errored += 1; + } + } + } + + // Bounded drain of the metadata queue. + // + // WHY drop-then-await: dropping the `MetadataQueue` closes the + // `Sender` half of the channel; the worker's `rx.recv()` + // returns `None` once the buffer is empty and the worker exits + // cleanly. Awaiting the `JoinHandle` with a timeout bounds the + // wait so a stuck extractor cannot hang the caller. + // + // WHY `no_wait_metadata` bypasses by dropping the queue without + // awaiting: callers that want the old fire-and-forget exit + // semantics can opt out; stragglers fall off the runtime when + // the caller returns. + if let Some(mut q) = queue.take() { + if no_wait_metadata { + drop(q); + } else { + let worker = q.take_worker(); + drop(q); + if let Some(handle) = worker { + match tokio::time::timeout(METADATA_DRAIN_TIMEOUT, handle).await { + Ok(Ok(())) => { + tracing::debug!("metadata queue drained cleanly"); + } + Ok(Err(e)) => { + tracing::warn!(error = %e, "metadata worker join failed"); + } + Err(_) => { + tracing::warn!( + "metadata queue did not drain within {METADATA_DRAIN_TIMEOUT:?}; \ + re-run `perima scan` or `perima metadata ` for stragglers", + ); + } + } + } + } + } + + report.interrupted = cancel.is_cancelled(); + report.duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); + + // Emit ScanCompleted only on successful, non-interrupted scans so the + // frontend doesn't trigger a stale refetch when the run was aborted. + // WHY Ok-only: a failed scan shouldn't trigger a frontend refetch. + // WHY None-volume guard: dry-run produces no VolumeId; skip the emit + // rather than fabricating a nil UUID for an event the frontend would + // misinterpret as a real volume. + if !report.interrupted + && let Some((vol_id, _)) = report.volume_mount + { + let event = perima_core::AppEvent::ScanCompleted { + volume: vol_id, + files_seen: report.files_seen, + files_new: report.files_new, + duration_ms: report.duration_ms, + }; + // WHY warn + non-fatal: bus failure (e.g. all receivers + // dropped at shutdown) must not abort the scan completion + // path. The scan result is already committed to SQLite. + if let Err(e) = self.events.emit(&event) { + tracing::warn!(error = %e, "failed to emit ScanCompleted; non-fatal"); + } + } + + Ok(report) + } +} + +/// Persist a single hashed file: upsert the content record, then the +/// location record. Returns the location outcome so the caller can +/// classify the result as new/existing. +fn persist_file( + repo: &dyn FileRepository, + d: &DiscoveredFile, + h: &BlakeHash, + device: DeviceId, + volume: VolumeId, +) -> Result { + let hf = HashedFile { + discovered: d.clone(), + hash: *h, + }; + repo.upsert_file(&hf, device)?; + repo.upsert_location(h, volume, &d.relative_path, device) +} + +fn validate_root(root: &Path) -> Result<(), CoreError> { + if !root.exists() { + return Err(CoreError::InvalidPath(format!( + "does not exist: {}", + root.display() + ))); + } + if !root.is_dir() { + return Err(CoreError::InvalidPath(format!( + "not a directory: {}", + root.display() + ))); + } + Ok(()) +} + +fn canonicalize_for_walk(root: &Path) -> Result { + // WHY: routes through `perima_fs::platform_path::canonicalize` — + // the single source of truth for the `#[cfg(windows)]` dunce / + // std fallback. + // WHY CoreError::from not CoreError::Io: Io is now a struct variant + // (Batch D Task 2) so it cannot be used as a function pointer. + perima_fs::platform_path::canonicalize(root).map_err(CoreError::from) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] // Test code; unwrap panics signal bugs. +mod tests { + use std::io::Write; + use std::sync::Mutex; + + use perima_core::{AppEvent, FileLocationRecord, MediaMetadata}; + use perima_db::{ + ReadPool, SqliteFileRepository, SqliteMetadataRepository, SqliteVolumeRepository, + SqliteWriter, SqliteWriterHandle, + }; + use perima_fs::WalkdirScanner; + use perima_hash::Blake3Service; + use tempfile::TempDir; + + use super::*; + + /// No-op event bus for tests that don't care about emissions. + struct NullBus; + impl EventBus for NullBus { + fn emit(&self, _event: &AppEvent) -> Result<(), CoreError> { + Ok(()) + } + } + + /// In-memory metadata repo that records upserts + thumbnail calls. + /// + /// WHY: `SqliteMetadataRepository` requires a separate connection; + /// a trivial mock keeps the test surface focused on whether the + /// queue spawns, not on metadata semantics (which have their own + /// coverage in `crates/media`). + #[derive(Default)] + struct RecordingMetadata { + upserts: Mutex>, + } + impl MetadataRepository for RecordingMetadata { + fn upsert_metadata( + &self, + meta: &MediaMetadata, + _device: DeviceId, + ) -> Result { + self.upserts.lock().unwrap().push(meta.hash); + Ok(UpsertOutcome::Inserted) + } + fn find_by_hash(&self, _hash: &BlakeHash) -> Result, CoreError> { + Ok(None) + } + fn list_with_metadata( + &self, + _limit: usize, + _volume: Option, + ) -> Result)>, CoreError> { + Ok(vec![]) + } + fn update_thumbnail( + &self, + _hash: &BlakeHash, + _path: Option<&str>, + _status: &str, + _device: DeviceId, + ) -> Result { + Ok(1) + } + } + + fn mk_fixture(dir: &Path) { + for (name, content) in [ + ("alpha.txt", b"alpha" as &[u8]), + ("sub/beta.txt", b"beta"), + ("sub/gamma.bin", b"\x00\x01\x02\x03"), + ] { + let path = dir.join(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::File::create(&path) + .unwrap() + .write_all(content) + .unwrap(); + } + } + + struct Harness { + // `_db_tmp` + `fixture` keep their TempDirs alive for the + // duration of the test; dropping them would delete the DB + + // fixture files underneath the running scan. + _db_tmp: TempDir, + fixture: TempDir, + uc: ScanUseCase, + recording_metadata: Arc, + // WHY hold the writer handle: the volume adapter is wired to + // this writer actor (post-Batch-C Task 2). Dropping the handle + // early would close the channel before the test completes. + _writer: SqliteWriterHandle, + } + + fn harness() -> Harness { + let db_tmp = tempfile::tempdir().unwrap(); + let fixture = tempfile::tempdir().unwrap(); + mk_fixture(fixture.path()); + + let db_path = db_tmp.path().join("perima.db"); + let writer = SqliteWriter::start(&db_path, Arc::new(NullBus)).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + + let files: Arc = + Arc::new(SqliteFileRepository::new(writer.sender(), reads.clone())); + let volumes: Arc = + Arc::new(SqliteVolumeRepository::new(writer.sender(), reads.clone())); + // Use the real metadata repo for the DB so persistence tests + // see a consistent view. A second recording mock is wired via + // Arc dyn but held out-of-band for tests that want it. + let _sqlite_meta: Arc = + Arc::new(SqliteMetadataRepository::new(writer.sender(), reads)); + let recording = Arc::new(RecordingMetadata::default()); + let metadata: Arc = recording.clone(); + + let scanner: Arc = Arc::new(WalkdirScanner::new()); + let hasher: Arc = Arc::new(Blake3Service::new()); + let thumbnailer = Arc::new(ThumbnailGenerator::disabled()); + let events: Arc = Arc::new(NullBus); + + let uc = ScanUseCase::new( + files, + volumes, + metadata, + scanner, + hasher, + thumbnailer, + events, + ); + Harness { + _db_tmp: db_tmp, + fixture, + uc, + recording_metadata: recording, + _writer: writer, + } + } + + #[tokio::test] + async fn dry_run_hashes_but_does_not_persist() { + let h = harness(); + let cmd = ScanCommand::Full(FullScan { + path: h.fixture.path().to_path_buf(), + device_id: DeviceId::new(), + with_metadata: false, + dry_run: true, + no_wait_metadata: true, + no_thumbnails: true, + cancel: CancellationToken::new(), + on_persist: None, + }); + let report = h.uc.execute(cmd).await.unwrap(); + assert_eq!(report.files_seen, 3, "all three fixture files walked"); + assert_eq!(report.files_new, 3, "dry-run counts hashed files as new"); + assert_eq!(report.files_errored, 0); + assert!( + report.bytes_hashed > 0, + "dry-run still hashes; bytes_hashed must be non-zero", + ); + assert!( + report.manifest_files.is_empty(), + "dry-run must not persist; manifest_files empty", + ); + assert!( + report.volume_mount.is_none(), + "dry-run skips volume detection", + ); + assert_eq!( + report.per_file_entries.len(), + 3, + "per_file_entries surfaced for every hashed file", + ); + } + + #[tokio::test] + async fn full_scan_persists_without_metadata() { + let h = harness(); + let cmd = ScanCommand::Full(FullScan { + path: h.fixture.path().to_path_buf(), + device_id: DeviceId::new(), + with_metadata: false, // queue must NOT spawn + dry_run: false, + no_wait_metadata: true, + no_thumbnails: true, + cancel: CancellationToken::new(), + on_persist: None, + }); + let report = h.uc.execute(cmd).await.unwrap(); + assert_eq!(report.files_seen, 3); + assert_eq!(report.files_new, 3, "first scan inserts all three"); + assert_eq!(report.files_errored, 0); + assert_eq!( + report.manifest_files.len(), + 3, + "each persisted file surfaces in manifest_files", + ); + assert!( + report.volume_mount.is_some(), + "non-dry-run records a volume mount", + ); + // Metadata queue did not spawn -> recording mock untouched. + assert!( + h.recording_metadata.upserts.lock().unwrap().is_empty(), + "with_metadata=false must not invoke the metadata repo", + ); + } + + #[tokio::test] + async fn rescan_is_idempotent() { + let h = harness(); + let device = DeviceId::new(); + let first = ScanCommand::Full(FullScan { + path: h.fixture.path().to_path_buf(), + device_id: device, + with_metadata: false, + dry_run: false, + no_wait_metadata: true, + no_thumbnails: true, + cancel: CancellationToken::new(), + on_persist: None, + }); + let first_report = h.uc.execute(first).await.unwrap(); + assert_eq!(first_report.files_new, 3); + + let second = ScanCommand::Rescan { + path: h.fixture.path().to_path_buf(), + device_id: device, + cancel: CancellationToken::new(), + }; + let second_report = h.uc.execute(second).await.unwrap(); + assert_eq!(second_report.files_seen, 3); + assert_eq!( + second_report.files_new, 0, + "Rescan over unchanged fixture must produce zero new rows", + ); + assert_eq!( + second_report.files_updated, 3, + "pre-existing rows land in files_updated (Unchanged outcome)", + ); + } + + #[tokio::test] + async fn cancellation_short_circuits_before_persist() { + let h = harness(); + let cancel = CancellationToken::new(); + // Cancel before execute to exercise the top-of-walk short-circuit. + cancel.cancel(); + let cmd = ScanCommand::Full(FullScan { + path: h.fixture.path().to_path_buf(), + device_id: DeviceId::new(), + with_metadata: false, + dry_run: true, + no_wait_metadata: true, + no_thumbnails: true, + cancel, + on_persist: None, + }); + let report = h.uc.execute(cmd).await.unwrap(); + assert!( + report.interrupted, + "cancel-before-execute must surface interrupted=true", + ); + // The walker `take_while(!cancelled)` yields zero items; nothing + // hashed -> bytes_hashed == 0. + assert_eq!(report.bytes_hashed, 0, "pre-cancelled walk hashes nothing"); + } +} diff --git a/crates/app/src/search.rs b/crates/app/src/search.rs new file mode 100644 index 0000000..2c3e15a --- /dev/null +++ b/crates/app/src/search.rs @@ -0,0 +1,266 @@ +//! `SearchUseCase` — orchestrates FTS5 full-text search and index rebuilds. +//! +//! This is the `crates/app` port of the search orchestration that +//! previously lived in `crates/cli/src/cmd/search.rs::run` (78 LOC) +//! and `crates/desktop/src/commands.rs::{search, search_rebuild}`. +//! Zero generics: dependency ports are carried as `Arc` +//! fields; a single `async fn execute(&self, cmd: SearchCommand) -> +//! Result` exposes both operations. +//! +//! See [`SearchUseCase::execute`] for the workflow body. + +use std::sync::Arc; +use std::time::Instant; + +use perima_core::{CoreError, EventBus, SearchHit, SearchRepository}; + +/// Inputs to [`SearchUseCase::execute`]. +#[derive(Debug, Clone)] +pub enum SearchCommand { + /// Run a FTS5 full-text query and return ranked hits. + /// + /// `limit` defaults to 50 when `None` — matches the CLI + /// `--limit` flag's `default_value = "50"`. + Query { + /// Raw FTS5 MATCH expression (e.g. `"vacation"`, `"image/jpeg"`). + q: String, + /// Maximum results. `None` → 50. + limit: Option, + }, + /// Wipe and rebuild the entire FTS5 index from the current DB state. + /// + /// WHY exposed as a command variant: needed after migrations that add + /// new indexed fields, and as a manual recovery tool when the index + /// drifts (e.g. after a crash mid-trigger). Mirrors CLI's + /// `perima search --rebuild` and Desktop's `search_rebuild` command. + Rebuild, +} + +/// Output of a successful search or rebuild. +#[derive(Debug, Clone)] +pub struct SearchOutput { + /// Ranked hits, best match first (BM25 ascending). + /// Empty for [`SearchCommand::Rebuild`]. + pub hits: Vec, + /// Wall-clock time of the repository call in milliseconds. + pub took_ms: u64, +} + +/// Orchestrator: FTS5 query + index rebuild. +/// +/// Dependencies are carried as `Arc` fields; there are zero +/// generic parameters on the struct itself. See [`SearchUseCase::execute`] +/// for the workflow body. +pub struct SearchUseCase { + search: Arc, + // WHY `events` is held but unused in the orchestration body today: + // Batch E will emit `FileEvent::SearchIndexRebuilt` (or equivalent) + // from here once the bus-engine swap lands. Holding the handle at + // construction makes the Batch-E diff a single-file addition rather + // than a signature churn across every caller. The field is silenced + // below with a `_ = &self.events` one-liner rather than `Arc::clone` + // to avoid an unnecessary refcount increment on every call. + events: Arc, +} + +impl std::fmt::Debug for SearchUseCase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SearchUseCase").finish_non_exhaustive() + } +} + +impl SearchUseCase { + /// Construct a `SearchUseCase` with the given dependency ports. + /// + /// The container (Task 7) calls this once and shares the resulting + /// `Arc` across surfaces. + #[must_use] + pub fn new(search: Arc, events: Arc) -> Self { + Self { search, events } + } + + /// Execute the search command. + /// + /// # Errors + /// - [`CoreError::Unsupported`] if [`SearchCommand::Query`] is given an + /// empty or whitespace-only query string. + /// - [`CoreError::Internal`] on `SQLite` / `FTS5` errors from the + /// repository. + // WHY allow unused_async: `SearchRepository` methods are synchronous today; + // the `async fn` signature is mandated by the UseCase contract so the + // Batch-C connection-actor swap (async write channel) can evolve the + // impl without touching callers. Removing `async` now would force a + // caller-side churn when the trait gains async variants. + #[allow(clippy::unused_async)] + pub async fn execute(&self, cmd: SearchCommand) -> Result { + // WHY touch self.events: held for the Batch-E event-emit path; + // reference the field so `unused` lints don't fire before + // Batch E wires the emissions. + let _ = &self.events; + + match cmd { + SearchCommand::Query { q, limit } => { + let query = q.trim(); + if query.is_empty() { + return Err(CoreError::Unsupported( + "search query must be non-empty".into(), + )); + } + let effective_limit = limit.unwrap_or(50); + let start = Instant::now(); + let hits = self.search.search(query, effective_limit)?; + let took_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); + Ok(SearchOutput { hits, took_ms }) + } + SearchCommand::Rebuild => { + let start = Instant::now(); + self.search.rebuild()?; + let took_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); + Ok(SearchOutput { + hits: vec![], + took_ms, + }) + } + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] // Test code; unwrap panics signal bugs. +mod tests { + use perima_core::AppEvent; + use perima_db::{ReadPool, SqliteSearchRepository, SqliteWriter}; + use tempfile::TempDir; + + use super::*; + + /// No-op event bus for tests that don't care about emissions. + struct NullBus; + impl EventBus for NullBus { + fn emit(&self, _event: &AppEvent) -> Result<(), CoreError> { + Ok(()) + } + } + + /// Build a [`SearchUseCase`] backed by a real `SQLite` DB in a tempdir. + fn harness() -> (SearchUseCase, TempDir) { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("perima.db"); + let writer = SqliteWriter::start(&db_path, Arc::new(NullBus) as Arc).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let repo: Arc = + Arc::new(SqliteSearchRepository::new(writer.sender(), reads)); + // WHY drop handle: sender inside repo keeps thread alive. + drop(writer); + let events: Arc = Arc::new(NullBus); + (SearchUseCase::new(repo, events), tmp) + } + + /// Seed a `search_content` row by opening a second connection. + /// + /// WHY insert into `search_content` directly: `SearchRepository::search` + /// queries `search_index` (the `FTS5` virtual table) which is populated + /// from `search_content` via triggers. Inserting into `search_content` + /// is the minimal path that avoids wiring a full scan pipeline just to + /// exercise search semantics. Using a second connection is fine under + /// `WAL` mode. + fn seed_via_conn(db_path: &std::path::Path, hash: &str, path: &str, mime: &str) { + use rusqlite::Connection; + let conn = Connection::open(db_path).unwrap(); + // WHY explicit column list: matches V007 `search_content` schema + // (blake3_hash, filename, relative_path, mime_type, camera_model, + // captured_at, tags). Omitting optional columns uses their DEFAULT ''. + conn.execute( + "INSERT INTO search_content \ + (blake3_hash, filename, relative_path, mime_type, camera_model, captured_at, tags) \ + VALUES (?1, '', ?2, ?3, '', '', '')", + rusqlite::params![hash, path, mime], + ) + .unwrap(); + } + + #[tokio::test] + async fn query_returns_matching_hits() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("perima.db"); + let writer = SqliteWriter::start(&db_path, Arc::new(NullBus) as Arc).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let repo: Arc = + Arc::new(SqliteSearchRepository::new(writer.sender(), reads)); + drop(writer); + let events: Arc = Arc::new(NullBus); + let uc = SearchUseCase::new(repo, events); + + seed_via_conn(&db_path, "aabbcc", "photos/vacation.jpg", "image/jpeg"); + + let out = uc + .execute(SearchCommand::Query { + q: "vacation".into(), + limit: None, + }) + .await + .unwrap(); + + assert_eq!(out.hits.len(), 1, "one seeded row matches 'vacation'"); + assert_eq!(out.hits[0].relative_path, "photos/vacation.jpg"); + assert_eq!(out.hits[0].blake3_hash, "aabbcc"); + } + + #[tokio::test] + async fn query_limit_is_respected() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("perima.db"); + let writer = SqliteWriter::start(&db_path, Arc::new(NullBus) as Arc).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let repo: Arc = + Arc::new(SqliteSearchRepository::new(writer.sender(), reads)); + drop(writer); + let events: Arc = Arc::new(NullBus); + let uc = SearchUseCase::new(repo, events); + + // Seed two rows matching "beach" + seed_via_conn(&db_path, "hash1", "beach/a.jpg", "image/jpeg"); + seed_via_conn(&db_path, "hash2", "beach/b.jpg", "image/jpeg"); + + let out = uc + .execute(SearchCommand::Query { + q: "beach".into(), + limit: Some(1), + }) + .await + .unwrap(); + + assert_eq!(out.hits.len(), 1, "limit=1 must cap results at 1"); + } + + #[tokio::test] + async fn rebuild_succeeds_and_returns_empty_hits() { + let (uc, _tmp) = harness(); + let out = uc.execute(SearchCommand::Rebuild).await.unwrap(); + assert!( + out.hits.is_empty(), + "Rebuild must return empty hits vec (no search performed)", + ); + // took_ms is timing-dependent; just verify it's a plausible value. + assert!( + out.took_ms < 60_000, + "took_ms should be sub-minute for rebuild" + ); + } + + #[tokio::test] + async fn empty_query_returns_unsupported_error() { + let (uc, _tmp) = harness(); + let err = uc + .execute(SearchCommand::Query { + q: " ".into(), + limit: None, + }) + .await + .unwrap_err(); + assert!( + matches!(err, CoreError::Unsupported(_)), + "whitespace-only query must return CoreError::Unsupported, got: {err:?}", + ); + } +} diff --git a/crates/app/src/tag.rs b/crates/app/src/tag.rs new file mode 100644 index 0000000..18df061 --- /dev/null +++ b/crates/app/src/tag.rs @@ -0,0 +1,443 @@ +//! `TagUseCase` — orchestrates tag CRUD and file-tag queries. +//! +//! This is the `crates/app` port of the tag orchestration that previously +//! lived in `crates/cli/src/cmd/tag.rs` (224 LOC) and +//! `crates/desktop/src/commands.rs::{list_tags_inner, attach_tag_inner, +//! detach_tag_inner, list_files_with_tags_inner}`. +//! +//! Zero generics: dependency ports are carried as `Arc` fields; +//! a single `async fn execute(&self, cmd: TagCommand) -> +//! Result` exposes all four operations. +//! +//! # Why a third field (`metadata`) +//! +//! `ListFilesWithTags` merges file-location rows (from `MetadataRepository`) +//! with their attached tags (from `TagRepository`). The plan skeleton shows +//! only the two primary fields (`tags`, `events`), but the +//! `ListFilesWithTags` variant is architecturally impossible without a +//! third port. The alternative — embedding a `MetadataRepository` reference +//! inside `TagRepository` — would violate single-responsibility. Adding the +//! field here is the smallest-surprise approach; `AppContainer` (Task 7) +//! wires it from the same `AppDeps`. +//! +//! # `Attached(u64)` / `Detached(u64)` semantics +//! +//! The `TagRepository::attach` + `detach` port methods return `Result<(), +//! CoreError>` — no rows-changed signal. A successful call counts as 1 +//! affected operation (idempotent re-attach is a no-op at DB level but +//! still returns `Ok(())`). The `UseCase` therefore returns `Attached(1)` / +//! `Detached(1)` for every successful invocation. This matches +//! `sqlite::changes()` semantics well enough for shell-layer logging; when +//! the Batch-C writer actor exposes an explicit rows-changed channel the +//! value can be refined. See [`TagOutput::Attached`] doc for the contract. +//! +//! See [`TagUseCase::execute`] for the workflow body. + +use std::sync::Arc; + +use perima_core::{ + BlakeHash, CoreError, DeviceId, EventBus, FileLocationRecord, MediaMetadata, + MetadataRepository, Tag, TagRepository, VolumeId, +}; + +/// A `(file-location, optional-metadata, tags)` triple returned by +/// [`TagCommand::ListFilesWithTags`]. +/// +/// WHY defined here (not in `perima-core`): `perima-core` contains domain +/// types and trait ports with zero framework deps. `FileWithTags` is an +/// aggregation convenience assembled by the app layer from three separate +/// repository results; it does not belong in the domain. Desktop's +/// `FileWithTagsPayload` + CLI's table-print code derive their own +/// presentation from this struct. Compare with `SearchOutput` (also +/// app-layer, not core). +#[derive(Debug, Clone)] +pub struct FileWithTags { + /// File location record (hash + relative path + volume + status). + pub location: FileLocationRecord, + /// Optional media metadata (None if extractor has not run yet). + pub metadata: Option, + /// Active tags for this content hash. + pub tags: Vec, +} + +/// Filter parameters for [`TagCommand::ListFilesWithTags`]. +/// +/// WHY defined in app (not core): purely an orchestration concern that +/// packs the two parameters `list_files_with_tags_inner` historically +/// took as positional args. Core ports are kept minimal; filter shapes +/// belong at the call site. +#[derive(Debug, Clone)] +pub struct TagFilter { + /// Maximum number of files to return. + pub limit: u32, + /// Optional volume to restrict results to. + pub volume: Option, +} + +impl Default for TagFilter { + /// Default: up to 500 files, no volume filter. + /// + /// WHY 500: matches the desktop command's implicit upper-bound and avoids + /// unbounded scans in the app layer without requiring callers to specify a + /// limit explicitly. + fn default() -> Self { + Self { + limit: 500, + volume: None, + } + } +} + +/// Inputs to [`TagUseCase::execute`]. +#[derive(Debug, Clone)] +pub enum TagCommand { + /// List all active (non-deleted) tags, sorted by name. + List, + + /// Upsert a tag by `name`, then attach it to the file identified by + /// `hash`. Idempotent — re-attaching an already-active `(hash, name)` + /// pair is a no-op at the DB level. + Attach { + /// Content hash of the target file. + hash: BlakeHash, + /// Tag name (will be normalized via `perima_core::normalize_tag` + /// inside `TagRepository::upsert_tag`). + name: String, + /// Device that initiated the operation (CRDT bookkeeping). + device: DeviceId, + }, + + /// Look up `name` via upsert (harmless if it doesn't exist), then + /// soft-delete the `file_tags` row linking `hash` to that tag. + Detach { + /// Content hash of the target file. + hash: BlakeHash, + /// Tag name to detach. + name: String, + /// Device that initiated the operation (CRDT bookkeeping). + device: DeviceId, + }, + + /// Return files with their associated tags, optionally filtered. + /// + /// `None` filter → [`TagFilter::default()`] (500 rows, no volume + /// restriction). + ListFilesWithTags { + /// Optional filter; defaults to 500 rows, all volumes. + filter: Option, + }, +} + +/// Output of a successful tag operation. +#[derive(Debug, Clone)] +pub enum TagOutput { + /// Response to [`TagCommand::List`] — all active tags sorted by name. + Tags(Vec), + + /// Response to [`TagCommand::Attach`]. + /// + /// The `u64` is always `1` for a successful attach (matches + /// `sqlite::changes()` semantics — see module doc for rationale). + Attached(u64), + + /// Response to [`TagCommand::Detach`]. + /// + /// The `u64` is always `1` for a successful detach call. A detach + /// targeting a non-existent or already-deleted link is still + /// `Detached(1)` at the `UseCase` boundary — the port contract returns + /// `Ok(())` in both cases. + Detached(u64), + + /// Response to [`TagCommand::ListFilesWithTags`]. + FilesWithTags(Vec), +} + +/// Orchestrator: tag list, attach, detach, and file-tag queries. +/// +/// Dependencies are carried as `Arc` fields; there are zero +/// generic parameters on the struct itself. See [`TagUseCase::execute`] +/// for the workflow body. +pub struct TagUseCase { + tags: Arc, + metadata: Arc, + // WHY `events` is held but unused in the orchestration body today: + // Batch E will emit `FileEvent::TagAttached` / `TagDetached` from here + // once the async-broadcast bus lands. Holding the handle at construction + // makes the Batch-E diff a single-file addition rather than a signature + // churn across every caller. The field is silenced below with a + // `_ = &self.events` one-liner (preferred zero-cost form — no refcount + // increment on each call, unlike `Arc::clone`). + events: Arc, +} + +impl std::fmt::Debug for TagUseCase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TagUseCase").finish_non_exhaustive() + } +} + +impl TagUseCase { + /// Construct a `TagUseCase` with the given dependency ports. + /// + /// The container (Task 7) calls this once and shares the resulting + /// `Arc` across surfaces. + /// + /// `metadata` is required for [`TagCommand::ListFilesWithTags`]; it merges + /// file-location rows from `MetadataRepository` with tag rows from + /// `TagRepository` (two-query merge in Rust — see module doc). + #[must_use] + pub fn new( + tags: Arc, + metadata: Arc, + events: Arc, + ) -> Self { + Self { + tags, + metadata, + events, + } + } + + /// Execute the tag command. + /// + /// # Errors + /// - [`CoreError::InvalidTag`] if `Attach`/`Detach` `name` fails + /// normalization (empty, whitespace-only, overlong). + /// - [`CoreError::Internal`] on `SQLite` failures from any repository. + // WHY allow unused_async: `TagRepository` + `MetadataRepository` methods + // are synchronous today; the `async fn` signature is mandated by the + // UseCase contract so the Batch-C connection-actor swap (async write + // channel) can evolve the impl without touching callers. Removing `async` + // now would force a caller-side churn when the trait gains async variants. + #[allow(clippy::unused_async)] + pub async fn execute(&self, cmd: TagCommand) -> Result { + // WHY touch self.events: held for the Batch-E event-emit path; + // reference the field so `unused` lints don't fire before Batch E + // wires the emissions. + let _ = &self.events; + + match cmd { + TagCommand::List => { + let tags = self.tags.list_tags()?; + Ok(TagOutput::Tags(tags)) + } + + TagCommand::Attach { hash, name, device } => { + let tag = self.tags.upsert_tag(&name, device)?; + self.tags.attach(&hash, tag.id, device)?; + Ok(TagOutput::Attached(1)) + } + + TagCommand::Detach { hash, name, device } => { + // WHY upsert_tag for detach: we need the tag's UUID to call + // `detach`. `upsert_tag` is idempotent — if the tag doesn't + // exist we create it (harmless), then `detach` finds no active + // row (no-op soft-delete). Mirrors `crates/cli/src/cmd/tag.rs:: + // run_rm` rationale. + let tag = self.tags.upsert_tag(&name, device)?; + self.tags.detach(&hash, tag.id, device)?; + Ok(TagOutput::Detached(1)) + } + + TagCommand::ListFilesWithTags { filter } => { + let f = filter.unwrap_or_default(); + // WHY two queries + merge (not a shared tx): the two-SELECT + // sequence has a benign WAL race — a `file_tags` insert between + // calls could produce tags for a hash not in the metadata set + // (harmless — we iterate the metadata list and look up by hash, + // so extra tags are ignored), and a metadata delete between + // calls leaves a stale tag entry in the map (also harmless for + // the same reason). Transient inconsistency is acceptable for + // UI list refresh. Mirrors `crates/desktop::commands:: + // list_files_with_tags_inner` rationale. + let rows = self + .metadata + .list_with_metadata(f.limit as usize, f.volume)?; + let hashes: Vec = rows.iter().map(|(loc, _)| loc.hash).collect(); + let tag_map = self.tags.tags_for_hashes(&hashes)?; + let files = rows + .into_iter() + .map(|(loc, meta)| { + let hash = loc.hash; + let tags = tag_map.get(&hash).cloned().unwrap_or_default(); + FileWithTags { + location: loc, + metadata: meta, + tags, + } + }) + .collect(); + Ok(TagOutput::FilesWithTags(files)) + } + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] // Test code; unwrap panics signal bugs. +mod tests { + use perima_core::{AppEvent, BlakeHash, DeviceId}; + use perima_db::{ + ReadPool, SqliteMetadataRepository, SqliteTagRepository, SqliteWriter, SqliteWriterHandle, + }; + use tempfile::TempDir; + + use super::*; + + /// No-op event bus for tests that don't care about emissions. + struct NullBus; + impl EventBus for NullBus { + fn emit(&self, _event: &AppEvent) -> Result<(), CoreError> { + Ok(()) + } + } + + /// Build a [`TagUseCase`] backed by a real `SQLite` DB in a tempdir. + /// + /// WHY single harness: every test uses this helper so setup is + /// consistent and the `TempDir` lifetime is managed uniformly. + /// The previous reviewer on `SearchUseCase` flagged inline-setup + /// inconsistency — we avoid that here. + /// + /// WHY the writer handle is returned: tests must keep it alive so + /// the writer thread outlives the `TagRepository` + + /// `MetadataRepository` handles (post-Batch-C Tasks 3 + 4 both + /// adapters hold a sender tied to this writer). + fn harness() -> (TagUseCase, TempDir, SqliteWriterHandle) { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("perima.db"); + // WHY writer + pool for both repos (Tasks 3 + 4): + // `SqliteTagRepository` + `SqliteMetadataRepository` now both + // hold `(flume::Sender, ReadPool)`; share the same + // writer sender + pool. + let events: Arc = Arc::new(NullBus); + let writer = SqliteWriter::start(&db_path, Arc::clone(&events)).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let tags: Arc = + Arc::new(SqliteTagRepository::new(writer.sender(), reads.clone())); + let metadata: Arc = + Arc::new(SqliteMetadataRepository::new(writer.sender(), reads)); + (TagUseCase::new(tags, metadata, events), tmp, writer) + } + + fn device() -> DeviceId { + DeviceId::new() + } + + fn sample_hash() -> BlakeHash { + BlakeHash::parse_hex(&"a".repeat(64)).unwrap() + } + + // ----------------------------------------------------------------------- + // List + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn list_returns_tags_present_in_db() { + let (uc, _tmp, _writer) = harness(); + let dev = device(); + + // Seed via Attach command. + uc.execute(TagCommand::Attach { + hash: sample_hash(), + name: "vacation".into(), + device: dev, + }) + .await + .unwrap(); + + let out = uc.execute(TagCommand::List).await.unwrap(); + let TagOutput::Tags(tags) = out else { + panic!("expected TagOutput::Tags"); + }; + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].name, "vacation"); + } + + // ----------------------------------------------------------------------- + // Attach + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn attach_creates_link_and_returns_attached_1() { + let (uc, _tmp, _writer) = harness(); + let dev = device(); + let hash = sample_hash(); + + let out = uc + .execute(TagCommand::Attach { + hash, + name: "trip".into(), + device: dev, + }) + .await + .unwrap(); + + assert!( + matches!(out, TagOutput::Attached(1)), + "expected Attached(1), got {out:?}" + ); + + // Verify the tag now appears in the DB. + let list_out = uc.execute(TagCommand::List).await.unwrap(); + let TagOutput::Tags(tags) = list_out else { + panic!("expected TagOutput::Tags"); + }; + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].name, "trip"); + } + + // ----------------------------------------------------------------------- + // Detach + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn detach_on_existing_link_returns_detached_1() { + let (uc, _tmp, _writer) = harness(); + let dev = device(); + let hash = sample_hash(); + + // First attach. + uc.execute(TagCommand::Attach { + hash, + name: "beach".into(), + device: dev, + }) + .await + .unwrap(); + + let out = uc + .execute(TagCommand::Detach { + hash, + name: "beach".into(), + device: dev, + }) + .await + .unwrap(); + + assert!( + matches!(out, TagOutput::Detached(1)), + "expected Detached(1), got {out:?}" + ); + } + + // ----------------------------------------------------------------------- + // ListFilesWithTags + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn list_files_with_tags_none_filter_returns_empty_on_fresh_db() { + let (uc, _tmp, _writer) = harness(); + + let out = uc + .execute(TagCommand::ListFilesWithTags { filter: None }) + .await + .unwrap(); + + let TagOutput::FilesWithTags(files) = out else { + panic!("expected TagOutput::FilesWithTags"); + }; + // Fresh DB has no file_metadata rows; list_with_metadata returns []. + assert!(files.is_empty(), "fresh DB should yield no files"); + } +} diff --git a/crates/app/src/telemetry.rs b/crates/app/src/telemetry.rs new file mode 100644 index 0000000..b07d267 --- /dev/null +++ b/crates/app/src/telemetry.rs @@ -0,0 +1,117 @@ +//! Shell-agnostic observability handlers. +//! +//! WHY this module exists: `LogEventHandler` was duplicated across the +//! CLI (`crates/cli/src/cmd/watch.rs`) and Desktop +//! (`crates/desktop/src/commands.rs`) shells pre-Batch-B. Task 10 of the +//! Batch B plan hoists it alongside [`crate::AppContainer`] so both +//! shells (and future `api` / `ffi` shells) construct the container +//! uniformly — the canonical home for shell-agnostic event handlers. +//! +//! `DbEventHandler` is deliberately NOT hoisted here: it touches +//! `SqliteFileRepository` which lives in `crates/db`, so hoisting it +//! would force `perima-app` to depend on a concrete adapter. It stays +//! shell-local in both CLI and Desktop. + +use crate::events::EventHandler; +use perima_core::AppEvent; + +/// Logs every application event at INFO level via `tracing`. +/// +/// WHY `Default` + `Debug`: wire-up sites construct this with +/// `Box::new(LogEventHandler)` today; derived `Default` keeps the +/// zero-field shape future-proof, and `Debug` makes the handler +/// printable inside `AppContainer` diagnostics without special-casing. +#[derive(Debug, Default)] +pub struct LogEventHandler; + +#[async_trait::async_trait] +impl EventHandler for LogEventHandler { + fn name(&self) -> &'static str { + "log_event_handler" + } + + async fn handle(&mut self, event: AppEvent) { + // WHY match on outer kind first: log statement varies per + // variant; the existing FileEvent log shape is preserved + // inside the AppEvent::File arm. + match event { + AppEvent::File(file_event) => { + // WHY `event = ?file_event` field name: preserves the + // log schema the pre-Batch-E `EventBus::emit` impl + // established. Downstream log consumers may rely on it. + tracing::info!(event = ?file_event, "file event"); + } + AppEvent::ScanCompleted { + volume, + files_new, + files_seen, + duration_ms, + } => { + tracing::info!( + ?volume, + files_new, + files_seen, + duration_ms, + "scan completed" + ); + } + AppEvent::IndexInvalidated { reason } => { + tracing::info!(?reason, "index invalidated"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use perima_core::{FileEvent, InvalidationReason, MediaPath, VolumeId}; + use uuid::Uuid; + + #[tokio::test] + async fn log_handler_handles_file_created() { + let mut handler = LogEventHandler; + let event = AppEvent::File(FileEvent::Created { + path: MediaPath::new("foo.txt"), + volume: VolumeId(Uuid::nil()), + }); + // `handle` returns (); success = no panic. + handler.handle(event).await; + } + + #[tokio::test] + async fn log_handler_handles_file_renamed() { + // WHY `LogEventHandler` (not `::default()`): clippy's + // `default_constructed_unit_structs` flags the latter on zero-field + // structs. The derived `Default` still matters for symmetry with + // future non-unit shapes and for `#[derive]` consumers. + let mut handler = LogEventHandler; + let event = AppEvent::File(FileEvent::Renamed { + from: MediaPath::new("a.txt"), + to: MediaPath::new("b.txt"), + volume: VolumeId(Uuid::nil()), + }); + handler.handle(event).await; + } + + #[tokio::test] + async fn log_handler_handles_scan_completed() { + let mut handler = LogEventHandler; + let event = AppEvent::ScanCompleted { + volume: VolumeId(Uuid::nil()), + files_seen: 10, + files_new: 3, + duration_ms: 500, + }; + handler.handle(event).await; + } + + #[tokio::test] + async fn log_handler_handles_index_invalidated() { + let mut handler = LogEventHandler; + let event = AppEvent::IndexInvalidated { + reason: InvalidationReason::TagsChanged, + }; + handler.handle(event).await; + } +} diff --git a/crates/app/src/volume.rs b/crates/app/src/volume.rs new file mode 100644 index 0000000..4a738af --- /dev/null +++ b/crates/app/src/volume.rs @@ -0,0 +1,321 @@ +//! `VolumeUseCase` — orchestrates volume list and mount-recording. +//! +//! This is the `crates/app` port of the volume orchestration that previously +//! lived in `crates/cli/src/cmd/volumes.rs` (54 LOC) and +//! `crates/desktop/src/commands.rs::{list_volumes_inner, scan}` +//! (`record_mount` call sites). +//! +//! Zero generics: dependency ports are carried as `Arc` fields; +//! a single `async fn execute(&self, cmd: VolumeCommand) -> +//! Result` exposes both operations. +//! +//! # Why two commands (not one) +//! +//! `List` (read-only, no side-effects) and `RecordMount` (write, CRDT-relevant) +//! have different callers: `list_volumes` is a Tauri query and CLI display; +//! `RecordMount` is called during scan startup when a volume is detected. +//! Folding them into one trait would conflate read + write concerns. Two +//! commands in one `UseCase` is the right grain — same repository, distinct +//! intent. +//! +//! # `DeviceId` on `RecordMount` +//! +//! `VolumeRepository::record_mount` takes a `DeviceId` (the "machine" that +//! observed the mount). The CLAUDE.md CRDT-prep rule (`updated_at + +//! device_id` on every mutable row) requires the caller to supply the +//! originating device. Carrying it per-command (not as a constructor field) +//! is consistent with `TagCommand::Attach` + `Detach` — callers know the +//! device context at call site, not at construction time. +//! +//! See [`VolumeUseCase::execute`] for the workflow body. + +use std::{path::PathBuf, sync::Arc}; + +use perima_core::{CoreError, DeviceId, EventBus, VolumeId, VolumeRecord, VolumeRepository}; + +/// Inputs to [`VolumeUseCase::execute`]. +#[derive(Debug, Clone)] +pub enum VolumeCommand { + /// List all volumes seen on `device`, including their current mount paths. + List { + /// Device (machine) whose mount records should be included. + /// + /// WHY per-command (not constructor): the same `VolumeUseCase` + /// instance may be called from contexts that differ by device (e.g. + /// multi-device CLI tooling). Keeping device here avoids a mutable + /// state change on the struct and matches `RecordMount`'s device + /// semantics. + device: DeviceId, + }, + + /// Record that `volume_id` was mounted at `path` on `device`. + /// + /// Idempotent: re-recording the same `(volume_id, device, path)` triple + /// is a no-op at the DB level. + RecordMount { + /// The volume being mounted. + volume_id: VolumeId, + /// Absolute path to the volume's mount point on the local machine. + path: PathBuf, + /// Device (machine) that observed this mount. + /// + /// WHY required: `volume_mounts` rows carry `device_id` for CRDT + /// scope isolation — each machine maintains its own mount-path + /// history independently of other machines in the library. + device: DeviceId, + }, +} + +/// Output of a successful volume operation. +#[derive(Debug, Clone)] +pub enum VolumeOutput { + /// Response to [`VolumeCommand::List`] — all volumes for the device. + Volumes(Vec), + + /// Response to [`VolumeCommand::RecordMount`] — the id of the volume + /// whose mount was recorded. + Recorded(VolumeId), +} + +/// Orchestrator: volume list and mount-recording. +/// +/// Dependencies are carried as `Arc` fields; there are zero +/// generic parameters on the struct itself. See [`VolumeUseCase::execute`] +/// for the workflow body. +pub struct VolumeUseCase { + volumes: Arc, + // WHY `events` is held but unused in the orchestration body today: + // Batch E will emit `VolumeEvent::MountRecorded` from here once the + // async-broadcast bus lands. Holding the handle at construction makes + // the Batch-E diff a single-file addition rather than a signature churn + // across every caller. The field is silenced below with a + // `_ = &self.events` one-liner (preferred zero-cost form — no refcount + // increment on each call, unlike `Arc::clone`). + events: Arc, +} + +impl std::fmt::Debug for VolumeUseCase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VolumeUseCase").finish_non_exhaustive() + } +} + +impl VolumeUseCase { + /// Construct a `VolumeUseCase` with the given dependency ports. + /// + /// The container (Task 7) calls this once and shares the resulting + /// `Arc` across surfaces. + #[must_use] + pub fn new(volumes: Arc, events: Arc) -> Self { + Self { volumes, events } + } + + /// Execute the volume command. + /// + /// # Errors + /// - [`CoreError::Internal`] on `SQLite` failures from the repository. + // WHY allow unused_async: `VolumeRepository` methods are synchronous + // today; the `async fn` signature is mandated by the UseCase contract so + // the Batch-C connection-actor swap (async write channel) can evolve the + // impl without touching callers. Removing `async` now would force a + // caller-side churn when the trait gains async variants. + #[allow(clippy::unused_async)] + pub async fn execute(&self, cmd: VolumeCommand) -> Result { + // WHY touch self.events: held for the Batch-E event-emit path; + // reference the field so `unused` lints don't fire before Batch E + // wires the emissions. + let _ = &self.events; + + match cmd { + VolumeCommand::List { device } => { + let records = self.volumes.list(device)?; + Ok(VolumeOutput::Volumes(records)) + } + + VolumeCommand::RecordMount { + volume_id, + path, + device, + } => { + self.volumes.record_mount(volume_id, device, &path)?; + Ok(VolumeOutput::Recorded(volume_id)) + } + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] // Test code; unwrap panics signal bugs. +mod tests { + use perima_core::{AppEvent, DeviceId, VolumeIdentifiers}; + use perima_db::{ReadPool, SqliteVolumeRepository, SqliteWriter, SqliteWriterHandle}; + use tempfile::TempDir; + + use super::*; + + /// No-op event bus for tests that don't care about emissions. + struct NullBus; + impl EventBus for NullBus { + fn emit(&self, _event: &AppEvent) -> Result<(), CoreError> { + Ok(()) + } + } + + /// Harness output: the `UseCase`, the tempdir (kept to anchor the + /// DB file lifetime), the shared adapter (for tests that seed via + /// the raw repo), and the writer handle (kept so the actor thread + /// outlives all tests until teardown). + struct Harness { + uc: VolumeUseCase, + tmp: TempDir, + repo: Arc, + _writer: SqliteWriterHandle, + } + + /// Build a [`VolumeUseCase`] backed by a real `SQLite` DB in a tempdir. + /// + /// WHY tempfile (not in-memory): post-Batch-C Task 2 the adapter + /// holds `(flume::Sender, ReadPool)`. The writer opens + /// one connection and the read pool opens its own; both need to + /// see the same DB. `Connection::open_in_memory()` is + /// per-connection private, so the two sides cannot share an + /// in-memory handle. A tempfile-backed DB works with WAL and is + /// cheap; the `TempDir` teardown runs on drop. + fn harness() -> Harness { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("perima.db"); + let bus: Arc = Arc::new(NullBus); + let writer = SqliteWriter::start(&db_path, bus).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let repo: Arc = + Arc::new(SqliteVolumeRepository::new(writer.sender(), reads)); + let events: Arc = Arc::new(NullBus); + let uc = VolumeUseCase::new(Arc::clone(&repo), events); + Harness { + uc, + tmp, + repo, + _writer: writer, + } + } + + fn device() -> DeviceId { + DeviceId::new() + } + + /// A minimal `VolumeIdentifiers` suitable for seeding test volumes. + fn test_ident(label: &str) -> VolumeIdentifiers { + VolumeIdentifiers { + gpt_partition_guid: None, + fs_uuid: Some(format!("test-uuid-{label}")), + label: Some(label.to_owned()), + capacity_bytes: 1_000_000, + is_removable: false, + } + } + + // ----------------------------------------------------------------------- + // List + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn list_returns_volumes_after_mount() { + let h = harness(); + let dev = device(); + + // Seed: find_or_create a volume then record a mount for it. + // WHY reuse the harness repo Arc for seeding: the writer actor + // is single-instance; opening a second `SqliteVolumeRepository` + // is not just unnecessary — the writer-actor pattern expects + // exactly one writer thread per process. Cloning the adapter + // Arc reuses the same writer sender. + let seed_repo = Arc::clone(&h.repo); + let ident = test_ident("TestVol"); + let vol_id = seed_repo.find_or_create(&ident, dev).unwrap(); + seed_repo + .record_mount(vol_id, dev, std::path::Path::new("/mnt/test")) + .unwrap(); + + let out = + h.uc.execute(VolumeCommand::List { device: dev }) + .await + .unwrap(); + let VolumeOutput::Volumes(records) = out else { + panic!("expected VolumeOutput::Volumes"); + }; + assert!( + !records.is_empty(), + "expected at least one volume after mount" + ); + assert_eq!(records[0].id, vol_id); + // WHY hold `tmp` until here: dropping it earlier would remove + // the DB file out from under the still-live writer thread. + drop(h.tmp); + } + + // ----------------------------------------------------------------------- + // RecordMount idempotency + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn record_mount_is_idempotent() { + let h = harness(); + let dev = device(); + let mount_path = PathBuf::from("/mnt/idempotent"); + + // Seed volume via the shared repo handle (single writer actor; + // see `list_returns_volumes_after_mount` rationale above). + let seed_repo = Arc::clone(&h.repo); + let ident = test_ident("IdempotentVol"); + let vol_id = seed_repo.find_or_create(&ident, dev).unwrap(); + + // Record the same mount twice via the UseCase. + let out1 = + h.uc.execute(VolumeCommand::RecordMount { + volume_id: vol_id, + path: mount_path.clone(), + device: dev, + }) + .await + .unwrap(); + let out2 = + h.uc.execute(VolumeCommand::RecordMount { + volume_id: vol_id, + path: mount_path.clone(), + device: dev, + }) + .await + .unwrap(); + + // Both calls must return Recorded with the same volume_id. + assert!( + matches!(out1, VolumeOutput::Recorded(id) if id == vol_id), + "first RecordMount should return Recorded(vol_id)" + ); + assert!( + matches!(out2, VolumeOutput::Recorded(id) if id == vol_id), + "second RecordMount should return Recorded(vol_id)" + ); + + // After two record_mount calls, list should still return exactly 1 volume. + let list_out = + h.uc.execute(VolumeCommand::List { device: dev }) + .await + .unwrap(); + let VolumeOutput::Volumes(records) = list_out else { + panic!("expected VolumeOutput::Volumes"); + }; + assert_eq!( + records.len(), + 1, + "idempotent record_mount should not create duplicate volume rows" + ); + // The single volume should have exactly one mount path entry. + assert_eq!( + records[0].mounts_on_this_machine.len(), + 1, + "idempotent record_mount should not duplicate mount paths" + ); + drop(h.tmp); + } +} diff --git a/crates/app/tests/bus.rs b/crates/app/tests/bus.rs new file mode 100644 index 0000000..2ff658f --- /dev/null +++ b/crates/app/tests/bus.rs @@ -0,0 +1,189 @@ +//! Bus behavior tests — multi-subscriber fan-out, slow-subscriber +//! isolation, capacity-Full warning path, receiver-Overflowed recovery. + +use std::sync::Arc; +use std::time::Duration; + +use async_broadcast::RecvError; +use perima_app::Bus; +use perima_core::{AppEvent, EventBus, FileEvent, MediaPath, VolumeId}; + +const fn nil_volume() -> VolumeId { + VolumeId(uuid::Uuid::nil()) +} + +fn file_event(name: &str) -> AppEvent { + AppEvent::File(FileEvent::Created { + path: MediaPath::new(name), + volume: nil_volume(), + }) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_subscriber_fanout() { + let bus: Arc = Bus::new(); + let mut a = bus.subscribe(); + let mut b = bus.subscribe(); + let mut c = bus.subscribe(); + + for i in 0..5 { + bus.emit(&file_event(&format!("f{i}.jpg"))).expect("emit"); + } + + for recv in [&mut a, &mut b, &mut c] { + for i in 0..5 { + let event = tokio::time::timeout(Duration::from_secs(1), recv.recv()) + .await + .expect("recv timeout") + .expect("recv ok"); + match event { + AppEvent::File(FileEvent::Created { path, .. }) => { + assert_eq!(path.as_str(), &format!("f{i}.jpg")); + } + other => panic!("unexpected event: {other:?}"), + } + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn slow_subscriber_does_not_block_fast() { + let bus: Arc = Bus::new(); + let mut fast = bus.subscribe(); + let mut slow = bus.subscribe(); + + // Spawn a slow receiver task that takes 50ms per event. + let slow_task = tokio::spawn(async move { + let mut count = 0; + while let Ok(_event) = slow.recv().await { + tokio::time::sleep(Duration::from_millis(50)).await; + count += 1; + if count >= 10 { + break; + } + } + count + }); + + // Publish 10 events fast. + for i in 0..10 { + bus.emit(&file_event(&format!("f{i}.jpg"))).expect("emit"); + } + + // Fast subscriber should drain all 10 within 200ms (each recv is ~free). + let start = std::time::Instant::now(); + for _ in 0..10 { + let _ = tokio::time::timeout(Duration::from_millis(200), fast.recv()) + .await + .expect("fast recv timeout — slow subscriber blocked us") + .expect("fast recv ok"); + } + let elapsed = start.elapsed(); + assert!( + elapsed < Duration::from_millis(500), + "fast subscriber drained 10 events in {elapsed:?} — slow blocked us" + ); + + // Slow subscriber eventually completes (10 events × 50ms = 500ms). + let slow_count = tokio::time::timeout(Duration::from_secs(2), slow_task) + .await + .expect("slow task timeout") + .expect("slow task panic"); + assert_eq!(slow_count, 10); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn full_inbox_logs_warns_and_returns_ok() { + // CAPACITY constant in Bus is 256; we emit 300 without recv to + // force the Full path. emit must always return Ok — the warning + // is observable via a tracing subscriber if needed but we just + // assert no panic + Ok return here. + let bus: Arc = Bus::new(); + let _recv = bus.subscribe(); // hold one receiver so channel is active + + for i in 0..300 { + let result = bus.emit(&file_event(&format!("f{i}.jpg"))); + assert!(result.is_ok(), "emit returned {result:?} on event {i}"); + } +} + +/// In backpressure mode (Bus default, `set_overflow(false)`): senders that exceed +/// capacity get `TrySendError::Full` (which Bus maps to `Ok(())`), and the receiver +/// can drain exactly the messages that fit. No `Overflowed` error in this mode. +/// +/// To exercise the `RecvError::Overflowed` path we use a raw channel with overflow +/// mode enabled — this tests the recovery contract we rely on inside the Bus's own +/// receiver logic (the `_inactive` holder), even though Bus itself is backpressure. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn receiver_overflowed_keeps_going() { + // WHY raw channel with overflow: Bus is always backpressure mode + // (set_overflow(false)), so the Overflowed RecvError path is not + // reachable via Bus::emit. We test the Overflowed recovery directly + // through a raw async_broadcast channel with overflow enabled. + let (mut sender, mut recv) = async_broadcast::broadcast::(256); + sender.set_overflow(true); + + // Push past capacity — overflow mode bumps oldest, sender succeeds. + for i in 0..300 { + sender + .try_broadcast(file_event(&format!("f{i}.jpg"))) + .expect("overflow-mode send should not fail"); + } + drop(sender); + + // First recv should return Overflowed indicating we lost events. + let first = recv.recv().await; + assert!( + matches!(first, Err(RecvError::Overflowed(_))), + "expected Overflowed err, got {first:?}" + ); + + // Subsequent recvs should yield events successfully. + let mut received = 0; + while let Ok(_event) = tokio::time::timeout(Duration::from_millis(100), recv.recv()) + .await + .unwrap_or(Err(RecvError::Closed)) + { + received += 1; + if received >= 256 { + break; + } + } + assert!( + received >= 200, + "expected to drain ~256 events post-Overflowed, got {received}" + ); +} + +/// Bus is in backpressure mode: emitting 300 events with one subscriber whose +/// inbox is full returns `Ok(())` for every call (the 45 excess are dropped +/// with a `tracing::warn!`). The subscriber drains exactly 256 messages. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn backpressure_mode_drops_excess_and_returns_ok() { + let bus: Arc = Bus::new(); + let mut recv = bus.subscribe(); + + // Publish 300 events without draining — first 256 fill the inbox, + // the rest are dropped silently (Bus logs warn, returns Ok). + for i in 0..300 { + let result = bus.emit(&file_event(&format!("f{i}.jpg"))); + assert!( + result.is_ok(), + "emit must always return Ok, got {result:?} at {i}" + ); + } + + // Drain all 256 buffered events. + let mut received = 0; + while tokio::time::timeout(Duration::from_millis(50), recv.recv()) + .await + .unwrap_or(Err(RecvError::Closed)) + .is_ok() + { + received += 1; + } + assert_eq!( + received, 256, + "expected exactly 256 buffered events, got {received}" + ); +} diff --git a/crates/app/tests/handler_spawn.rs b/crates/app/tests/handler_spawn.rs new file mode 100644 index 0000000..3071846 --- /dev/null +++ b/crates/app/tests/handler_spawn.rs @@ -0,0 +1,164 @@ +//! Handler spawn behavior — task lifetime, panic recovery. +//! +//! These tests exercise `recv_loop` indirectly via the public +//! `AppContainer::new` API once Task 6 lands. For Task 4 standalone, +//! we test the trait + a manual spawn pattern that mirrors what +//! `AppContainer::new` will do. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_broadcast::Receiver; +use perima_app::{Bus, EventHandler}; +use perima_core::{AppEvent, EventBus, FileEvent, MediaPath, VolumeId}; + +const fn nil_volume() -> VolumeId { + VolumeId(uuid::Uuid::nil()) +} + +fn file_event(name: &str) -> AppEvent { + AppEvent::File(FileEvent::Created { + path: MediaPath::new(name), + volume: nil_volume(), + }) +} + +/// Counting handler — increments a shared counter on every event. +struct CountingHandler { + counter: Arc>, +} + +#[async_trait::async_trait] +impl EventHandler for CountingHandler { + fn name(&self) -> &'static str { + "counting_handler" + } + async fn handle(&mut self, _event: AppEvent) { + *self.counter.lock().expect("counting_handler counter lock") += 1; + } +} + +/// Panicking-then-recovering handler — panics on first call, succeeds after. +struct PanicOnceHandler { + counter: Arc>, + has_panicked: Arc>, +} + +#[async_trait::async_trait] +impl EventHandler for PanicOnceHandler { + fn name(&self) -> &'static str { + "panic_once_handler" + } + async fn handle(&mut self, _event: AppEvent) { + // WHY: read + set the flag in a scoped block so the `MutexGuard` + // is dropped before we panic. Panicking while holding a `MutexGuard` + // poisons the mutex, which would cause the second call's + // `.lock().expect(...)` to panic with `PoisonError` rather than + // incrementing the counter. + let should_panic = { + let mut p = self + .has_panicked + .lock() + .expect("panic_once_handler has_panicked lock"); + if *p { + false + } else { + *p = true; + true + } + }; + assert!(!should_panic, "first call panic"); + *self + .counter + .lock() + .expect("panic_once_handler counter lock") += 1; + } +} + +/// Mirror what `AppContainer::new` will do — spawn the handler task. +/// Calls `perima_app::events::recv_loop` indirectly by inlining its +/// shape (`recv_loop` is `pub(crate)`; for the test we re-implement the +/// loop inline). After Task 6, this test re-points at `AppContainer::new`. +fn spawn_handler_task( + bus: &Arc, + handler: Box, +) -> tokio::task::JoinHandle<()> { + let recv = bus.subscribe(); + tokio::spawn(run_loop(handler, recv)) +} + +async fn run_loop(mut handler: Box, mut recv: Receiver) { + use futures::FutureExt; + let name = handler.name(); + loop { + match recv.recv().await { + Ok(event) => { + let result = std::panic::AssertUnwindSafe(handler.handle(event)) + .catch_unwind() + .await; + if let Err(panic) = result { + tracing::error!(handler = name, panic = ?panic, "panic; continuing"); + } + } + Err(async_broadcast::RecvError::Overflowed(n)) => { + tracing::warn!(handler = name, missed = n, "lag"); + } + Err(async_broadcast::RecvError::Closed) => return, + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn handler_runs_until_bus_dropped() { + let bus = Bus::new(); + let counter = Arc::new(Mutex::new(0_usize)); + let handler = Box::new(CountingHandler { + counter: Arc::clone(&counter), + }); + let task = spawn_handler_task(&bus, handler); + + bus.emit(&file_event("a.jpg")).expect("emit"); + bus.emit(&file_event("b.jpg")).expect("emit"); + + // Give the task time to drain. + tokio::time::sleep(Duration::from_millis(100)).await; + + assert_eq!(*counter.lock().expect("counter lock"), 2); + + // Drop the bus → channel sender drops → recv returns Closed → task exits. + // WHY this works: `Bus._inactive` is an `InactiveReceiver` (does NOT prevent + // sender-close from propagating `Closed` to active receivers). Dropping `Bus` + // drops the only `Sender`, so all active `recv()` calls immediately return + // `RecvError::Closed`. + drop(bus); + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("task timeout") + .expect("task panic"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn handler_panic_recovers_once() { + let bus = Bus::new(); + let counter = Arc::new(Mutex::new(0_usize)); + let has_panicked = Arc::new(Mutex::new(false)); + let handler = Box::new(PanicOnceHandler { + counter: Arc::clone(&counter), + has_panicked: Arc::clone(&has_panicked), + }); + let _task = spawn_handler_task(&bus, handler); + + // First emit triggers the panic; recv_loop logs + continues. + bus.emit(&file_event("a.jpg")).expect("emit"); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + *has_panicked.lock().expect("has_panicked lock"), + "expected first call to panic" + ); + assert_eq!(*counter.lock().expect("counter lock"), 0); + + // Second emit: panic flag set, handler increments counter. + bus.emit(&file_event("b.jpg")).expect("emit"); + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(*counter.lock().expect("counter lock"), 1); +} diff --git a/crates/app/tests/scan_emits_completed.rs b/crates/app/tests/scan_emits_completed.rs new file mode 100644 index 0000000..a0f69f4 --- /dev/null +++ b/crates/app/tests/scan_emits_completed.rs @@ -0,0 +1,178 @@ +//! Verify `ScanUseCase::execute` emits `AppEvent::ScanCompleted` after a +//! successful (non-dry-run, non-interrupted) scan. + +#![allow(clippy::unwrap_used)] // Test code; unwrap panics signal bugs. + +use std::io::Write as _; +use std::sync::{Arc, Mutex}; + +use perima_app::{FullScan, ScanCommand, ScanUseCase}; +use perima_core::{ + AppEvent, CoreError, EventBus, FileRepository, HashService, MetadataRepository, Scanner, + VolumeRepository, +}; +use perima_db::{ + ReadPool, SqliteFileRepository, SqliteMetadataRepository, SqliteVolumeRepository, SqliteWriter, +}; +use perima_fs::WalkdirScanner; +use perima_hash::Blake3Service; +use perima_media::ThumbnailGenerator; +use tempfile::TempDir; +use tokio_util::sync::CancellationToken; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// An `EventBus` that records every emitted `AppEvent`. +#[derive(Default)] +struct RecordingBus { + events: Mutex>, +} + +impl EventBus for RecordingBus { + fn emit(&self, e: &AppEvent) -> Result<(), CoreError> { + self.events + .lock() + .expect("RecordingBus mutex poisoned") + .push(e.clone()); + Ok(()) + } +} + +/// Minimal fixture: three files that the scanner will walk + hash. +fn mk_fixture(dir: &std::path::Path) { + for (name, content) in [ + ("alpha.txt", b"alpha" as &[u8]), + ("sub/beta.txt", b"beta"), + ("sub/gamma.bin", b"\x00\x01\x02"), + ] { + let path = dir.join(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::File::create(&path) + .unwrap() + .write_all(content) + .unwrap(); + } +} + +/// No-op event bus — used for the `SqliteWriter` so its `IndexInvalidated` +/// events don't land in the `RecordingBus` (keeping assertion simple). +struct NullBus; +impl EventBus for NullBus { + fn emit(&self, _e: &AppEvent) -> Result<(), CoreError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Test +// --------------------------------------------------------------------------- + +/// A successful non-dry-run scan must emit exactly one `ScanCompleted` event +/// on the bus passed to `ScanUseCase::new`. +/// +/// Other events (e.g. `IndexInvalidated::FilesChanged` from the writer actor) +/// may also be present — the assertion only checks that `ScanCompleted` exists. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn scan_use_case_emits_scan_completed_on_success() { + // ---- wire-up ----------------------------------------------------------- + let db_tmp = TempDir::new().unwrap(); + let fixture = TempDir::new().unwrap(); + mk_fixture(fixture.path()); + + let db_path = db_tmp.path().join("perima.db"); + // WHY NullBus for writer: the writer emits IndexInvalidated events; we + // pass NullBus so those don't appear in `recording_bus` — makes the + // ScanCompleted assertion unambiguous. + let writer = SqliteWriter::start(&db_path, Arc::new(NullBus)).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + + let files: Arc = + Arc::new(SqliteFileRepository::new(writer.sender(), reads.clone())); + let volumes: Arc = + Arc::new(SqliteVolumeRepository::new(writer.sender(), reads.clone())); + let metadata: Arc = + Arc::new(SqliteMetadataRepository::new(writer.sender(), reads)); + + let scanner: Arc = Arc::new(WalkdirScanner::new()); + let hasher: Arc = Arc::new(Blake3Service::new()); + let thumbnailer = Arc::new(ThumbnailGenerator::disabled()); + + let recording_bus = Arc::new(RecordingBus::default()); + // The recording bus is the `events` arg — ScanUseCase will emit + // ScanCompleted on it after execute_full succeeds. + let events_arc: Arc = recording_bus.clone(); + + let uc = ScanUseCase::new( + files, + volumes, + metadata, + scanner, + hasher, + thumbnailer, + events_arc, + ); + + // ---- execute ----------------------------------------------------------- + let device_id = perima_core::DeviceId::new(); + let cmd = ScanCommand::Full(FullScan { + path: fixture.path().to_path_buf(), + device_id, + with_metadata: false, + dry_run: false, + no_wait_metadata: true, + no_thumbnails: true, + cancel: CancellationToken::new(), + on_persist: None, + }); + + let report = uc.execute(cmd).await.expect("scan should succeed"); + assert_eq!(report.files_seen, 3, "sanity: fixture has 3 files"); + + // ---- assert ------------------------------------------------------------ + // WHY no sleep: emit() is synchronous and happens before Ok(report) + // returns. By the time execute() returns the event is already in the vec. + // WHY clone + drop guard: release the mutex before the assertions so the + // guard doesn't live across the drop(writer) at the end of the test, + // avoiding the `significant_drop_tightening` lint. + let captured: Vec = recording_bus.events.lock().unwrap().clone(); + + let scan_completed = captured + .iter() + .find(|e| matches!(e, AppEvent::ScanCompleted { .. })); + + assert!( + scan_completed.is_some(), + "expected AppEvent::ScanCompleted in bus events, got: {captured:?}", + ); + + // Verify the payload fields match the scan report. + if let Some(AppEvent::ScanCompleted { + files_seen, + files_new, + duration_ms, + .. + }) = scan_completed + { + assert_eq!(*files_seen, 3, "ScanCompleted.files_seen matches report"); + assert_eq!( + *files_new, 3, + "ScanCompleted.files_new: first scan inserts all" + ); + assert!( + *duration_ms > 0, + "ScanCompleted.duration_ms must be non-zero" + ); + } + + // WHY explicit drop order: writer must outlive all repo handles so the + // actor thread sees a clean shutdown rather than a broken channel. + // TempDir is dropped last to avoid the DB file disappearing while the + // writer is still flushing. + drop(writer); + drop(db_tmp); + drop(fixture); +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 1c314cc..02b8f03 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -17,10 +17,14 @@ perima-db = { path = "../db" } # commit must propagate to `perima` so the CLI rev matches the media rev # it links against. perima-media = { workspace = true } +# WHY workspace = true: release-plz propagates Batch-B app-layer bumps +# into the CLI, same rationale as perima-media above. +perima-app = { workspace = true } chrono.workspace = true serde_json.workspace = true serde.workspace = true +async-trait.workspace = true clap.workspace = true miette.workspace = true tracing.workspace = true diff --git a/crates/cli/src/cmd/ls.rs b/crates/cli/src/cmd/ls.rs index 0f365dd..7002def 100644 --- a/crates/cli/src/cmd/ls.rs +++ b/crates/cli/src/cmd/ls.rs @@ -1,11 +1,20 @@ -//! `perima ls` implementation. +//! `perima ls` — thin delegator to [`perima_app::MetadataUseCase`] with a +//! small CLI-side post-filter for `--volume` + `--tag` args that the +//! `UseCase` does not yet surface through its command enum. +//! +//! WHY use `container.tags` for `--tag`: the +//! `TagRepository::files_with_tag` port is not exposed through +//! `TagUseCase::List` output. Post-Batch-C Task 3, `AppContainer` +//! exposes `Arc` directly so the filter path no +//! longer needs a short-lived `SqliteTagRepository::new(...)` open. +//! A future `UseCase` extension can lift this into `MetadataCommand`. use std::collections::HashSet; use std::io::Write; +use perima_app::{AppContainer, MetadataCommand, MetadataOutput}; use perima_core::{ - BlakeHash, CoreError, FileLocationRecord, FileRepository, MediaMetadata, MetadataRepository, - TagRepository, VolumeId, normalize_tag, + BlakeHash, CoreError, DeviceId, FileLocationRecord, MediaMetadata, VolumeId, normalize_tag, }; /// Arguments for the ls command. @@ -18,12 +27,6 @@ pub(crate) struct LsArgs { /// Output as JSON instead of a human-readable table. pub json: bool, /// Include media metadata columns (`captured_at`, dimensions, `camera_model`). - /// - /// WHY opt-in flag: the `--with-metadata` path uses a LEFT JOIN - /// against `file_metadata`, which is slightly more expensive than - /// the base `list_file_locations` query and returns extra columns - /// that older scripts do not expect. Keeping the default narrow - /// preserves v0.3.x output stability. pub with_metadata: bool, /// Filter to files carrying this tag (normalized before lookup). pub tag: Option, @@ -31,87 +34,121 @@ pub(crate) struct LsArgs { /// Execute `ls`. /// -/// Reads all file location records from `repo` (up to `args.limit`) -/// and prints them either as a human-readable table or as JSON. When -/// `args.with_metadata` is `true`, the listing routes through -/// `metadata_repo` so each row is joined with its (optional) -/// `file_metadata`. When `args.tag` is `Some`, only files whose -/// content hash carries the named tag are shown. +/// Reads file-location records (optionally joined with metadata) through +/// [`perima_app::MetadataUseCase`], then post-filters by `--volume` + +/// `--tag` in memory. Prints either a human-readable table or JSON. /// /// # Errors -/// Propagates `CoreError` from the repository. -pub(crate) fn run( - repo: &R, - metadata_repo: &M, - tag_repo: &T, +/// Propagates `CoreError` from the `UseCase` / repositories. +pub(crate) async fn run( + container: &AppContainer, + _data_dir: &std::path::Path, + device: DeviceId, args: &LsArgs, -) -> Result<(), CoreError> -where - R: FileRepository + ?Sized, - M: MetadataRepository + ?Sized, - T: TagRepository + ?Sized, -{ - // Build the optional tag-filter hash set before any repo queries. - // WHY eager: errors on bad tag names should surface before any output - // is written to stdout, so the user gets a clean error message. +) -> Result<(), CoreError> { + // Build the optional tag-filter hash set before any repo queries so + // a bad tag name errors cleanly before output. let tag_filter: Option> = args .tag .as_deref() - .map(|raw| build_tag_filter(tag_repo, raw)) + .map(|raw| build_tag_filter(container, raw)) .transpose()?; + // WHY limit widening: downstream post-filters by volume/tag reduce the + // pool. If the caller passed `--limit 100 --volume X` and we only fetch + // 100 pre-filter, we may lose rows on volume X. For shell use with a + // small index this is fine; the fully-correct fix is a + // `MetadataCommand` with volume + tag filters baked in (follow-up). + let effective_limit: u32 = u32::try_from(args.limit).unwrap_or(u32::MAX); + if args.with_metadata { - let rows = metadata_repo.list_with_metadata(args.limit, args.volume)?; + let out = container + .metadata + .execute(MetadataCommand::ListFilesWithMetadata { + limit: Some(effective_limit), + offset: None, + device, + }) + .await?; + let MetadataOutput::FilesWithMetadata(rows) = out else { + return Err(CoreError::Internal( + "ListFilesWithMetadata returned non-FilesWithMetadata output".into(), + )); + }; + let rows = apply_metadata_volume_filter(rows, args.volume); let rows = apply_metadata_tag_filter(rows, tag_filter.as_ref()); if args.json { let stdout = std::io::stdout(); let mut handle = stdout.lock(); serde_json::to_writer_pretty(&mut handle, &rows) .map_err(|e| CoreError::Internal(format!("json: {e}")))?; - writeln!(handle).map_err(CoreError::Io)?; + writeln!(handle).map_err(CoreError::from)?; } else { print_table_with_metadata(&rows)?; } return Ok(()); } - let records = repo.list_file_locations(args.limit, args.volume)?; + let out = container + .metadata + .execute(MetadataCommand::ListFiles { + limit: Some(effective_limit), + offset: None, + device, + }) + .await?; + let MetadataOutput::Files(records) = out else { + return Err(CoreError::Internal( + "ListFiles returned non-Files output".into(), + )); + }; + let records = apply_volume_filter(records, args.volume); let records = apply_tag_filter(records, tag_filter.as_ref()); if args.json { let stdout = std::io::stdout(); let mut handle = stdout.lock(); serde_json::to_writer_pretty(&mut handle, &records) .map_err(|e| CoreError::Internal(format!("json: {e}")))?; - writeln!(handle).map_err(CoreError::Io)?; + writeln!(handle).map_err(CoreError::from)?; } else { print_table(&records)?; } Ok(()) } -/// Look up a tag by name and collect all hashes that carry it. -/// -/// WHY returns `HashSet`: the caller performs membership tests once per -/// location record. A `Vec` lookup would be O(n·m) for large libraries; -/// a `HashSet` makes each test O(1). -fn build_tag_filter(tag_repo: &T, raw: &str) -> Result, CoreError> -where - T: TagRepository + ?Sized, -{ +/// Look up a tag by name via the container's tag port and collect all +/// hashes that carry it. +fn build_tag_filter(container: &AppContainer, raw: &str) -> Result, CoreError> { let normalized = normalize_tag(raw)?; - let all_tags = tag_repo.list_tags()?; + let all_tags = container.tags.list_tags()?; let tag = all_tags .into_iter() .find(|t| t.name == normalized) .ok_or_else(|| CoreError::NotFound(format!("tag not found: {normalized}")))?; - let hashes = tag_repo.files_with_tag(tag.id)?; + let hashes = container.tags.files_with_tag(tag.id)?; Ok(hashes.into_iter().collect()) } -/// Retain only records whose hash is in `filter`. -/// -/// WHY consumes the vec: filtering in-place avoids an extra allocation -/// and the caller no longer needs the original unfiltered list. +fn apply_volume_filter( + records: Vec, + volume: Option, +) -> Vec { + match volume { + None => records, + Some(v) => records.into_iter().filter(|r| r.volume_id == v).collect(), + } +} + +fn apply_metadata_volume_filter( + rows: Vec<(FileLocationRecord, Option)>, + volume: Option, +) -> Vec<(FileLocationRecord, Option)> { + match volume { + None => rows, + Some(v) => rows.into_iter().filter(|(r, _)| r.volume_id == v).collect(), + } +} + fn apply_tag_filter( records: Vec, filter: Option<&HashSet>, @@ -125,7 +162,6 @@ fn apply_tag_filter( } } -/// Retain only `(location, meta)` pairs whose hash is in `filter`. fn apply_metadata_tag_filter( rows: Vec<(FileLocationRecord, Option)>, filter: Option<&HashSet>, @@ -147,7 +183,7 @@ fn print_table(records: &[FileLocationRecord]) -> Result<(), CoreError> { "{:<10} {:<10} {:<10} PATH", "HASH", "SIZE", "VOLUME", ) - .map_err(CoreError::Io)?; + .map_err(CoreError::from)?; for r in records { let hash_hex = r.hash.to_hex(); let hash_short = &hash_hex[..8]; @@ -159,18 +195,12 @@ fn print_table(records: &[FileLocationRecord]) -> Result<(), CoreError> { "{hash_short}… {size:<10} {vol_short}… {}", r.relative_path.as_str() ) - .map_err(CoreError::Io)?; + .map_err(CoreError::from)?; } Ok(()) } /// Render `ls --with-metadata` as a human-readable table. -/// -/// WHY separate helper (not a branch inside [`print_table`]): the two -/// tables have different column counts and different `writeln!` format -/// strings; sharing the body would mean nullable placeholders for the -/// metadata columns on plain `ls`, which is more confusing than a -/// parallel function. fn print_table_with_metadata( rows: &[(FileLocationRecord, Option)], ) -> Result<(), CoreError> { @@ -181,7 +211,7 @@ fn print_table_with_metadata( "{:<10} {:<10} {:<10} {:<20} {:<10} {:<20} PATH", "HASH", "SIZE", "VOLUME", "CAPTURED_AT", "DIMS", "CAMERA", ) - .map_err(CoreError::Io)?; + .map_err(CoreError::from)?; for (r, meta) in rows { let hash_hex = r.hash.to_hex(); let hash_short = &hash_hex[..8]; @@ -208,7 +238,7 @@ fn print_table_with_metadata( "{hash_short}… {size:<10} {vol_short}… {captured_at:<20} {dims:<10} {camera:<20} {}", r.relative_path.as_str() ) - .map_err(CoreError::Io)?; + .map_err(CoreError::from)?; } Ok(()) } diff --git a/crates/cli/src/cmd/metadata.rs b/crates/cli/src/cmd/metadata.rs index 8a1e0d4..b32a7f6 100644 --- a/crates/cli/src/cmd/metadata.rs +++ b/crates/cli/src/cmd/metadata.rs @@ -11,12 +11,9 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; +use perima_app::AppContainer; use perima_core::{ - CoreError, DeviceId, FileLocationRecord, FileRepository, MediaMetadata, MetadataExtractor, - MetadataRepository, VolumeRepository, -}; -use perima_db::{ - SqliteFileRepository, SqliteMetadataRepository, SqliteVolumeRepository, open_and_migrate, + CoreError, DeviceId, FileLocationRecord, MediaMetadata, MetadataExtractor, MetadataRepository, }; use perima_media::{ CompositeExtractor, ImageExtractor, MetadataQueue, ThumbnailGenerator, VideoExtractor, @@ -49,13 +46,14 @@ pub(crate) struct MetadataArgs { /// is not yet indexed (run `perima scan` first); propagates /// [`CoreError`] from volume detection, DB access, and the extractor. pub(crate) async fn run( + container: &AppContainer, data_dir: &Path, device: DeviceId, args: &MetadataArgs, ) -> Result<(), CoreError> { validate_file(&args.path)?; let absolute_path = - perima_fs::platform_path::canonicalize(&args.path).map_err(CoreError::Io)?; + perima_fs::platform_path::canonicalize(&args.path).map_err(CoreError::from)?; // Resolve volume from the containing directory. WHY parent(): volume // detection inspects the mount point; a file path's parent is the @@ -65,18 +63,20 @@ pub(crate) async fn run( .ok_or_else(|| CoreError::InvalidPath(format!("no parent: {}", absolute_path.display())))?; let detected = perima_fs::detect_volume(parent)?; - let db_path = data_dir.join("perima.db"); - - // WHY three connections: each repo owns its `Mutex`, and - // under WAL mode a fresh open is ~microseconds. Sharing a single - // connection across repos would require wrapping it in another layer - // of `Mutex`, which none of the repos' `new(...)` constructors accept. - let vol_repo = SqliteVolumeRepository::new(open_and_migrate(&db_path)?); - let volume_id = vol_repo.find_or_create(&detected.identifiers, device)?; - drop(vol_repo); + // WHY delegate to `container.volumes` post-Batch-C Task 2: the + // writer actor owns the sole writable connection; opening a second + // one just for `find_or_create` would require a second writer + // (spec §3.1 forbids). The container holds the shared adapter. + let volume_id = container + .volumes + .find_or_create(&detected.identifiers, device)?; - let file_repo = SqliteFileRepository::new(open_and_migrate(&db_path)?); - let metadata_repo = Arc::new(SqliteMetadataRepository::new(open_and_migrate(&db_path)?)); + // WHY via container.metadata_repo + container.files_repo (post-Batch-C + // Task 7): the writer actor owns the sole writable connection. Using + // the shared adapters from the container avoids opening a second + // writer here. `migrate_sentinel_row` is not needed here (no sentinel + // seam in the metadata command). + let metadata_repo: Arc = Arc::clone(&container.metadata_repo); // WHY suffix match on the absolute path: `scan` walker stores // paths relative to the *scan root* (see @@ -89,7 +89,9 @@ pub(crate) async fn run( let absolute_str = absolute_path.to_str().ok_or_else(|| { CoreError::InvalidPath(format!("non-UTF8 path: {}", absolute_path.display())) })?; - let records = file_repo.list_file_locations(usize::MAX, Some(volume_id))?; + let records = container + .files_repo + .list_file_locations(usize::MAX, Some(volume_id))?; let Some(record) = find_by_absolute_suffix(&records, absolute_str) else { return Err(CoreError::InvalidPath(format!( "not indexed: {} (run `perima scan` first)", @@ -214,38 +216,38 @@ fn print_json(meta: &MediaMetadata) -> Result<(), CoreError> { let mut handle = stdout.lock(); serde_json::to_writer_pretty(&mut handle, meta) .map_err(|e| CoreError::Internal(format!("json: {e}")))?; - writeln!(handle).map_err(CoreError::Io) + writeln!(handle).map_err(CoreError::from) } fn print_table(meta: &MediaMetadata, record: &FileLocationRecord) -> Result<(), CoreError> { let stdout = std::io::stdout(); let mut handle = stdout.lock(); let hash_hex = meta.hash.to_hex(); - writeln!(handle, "hash: {hash_hex}").map_err(CoreError::Io)?; - writeln!(handle, "path: {}", record.relative_path.as_str()).map_err(CoreError::Io)?; + writeln!(handle, "hash: {hash_hex}").map_err(CoreError::from)?; + writeln!(handle, "path: {}", record.relative_path.as_str()).map_err(CoreError::from)?; if let Some(m) = &meta.mime_type { - writeln!(handle, "mime: {m}").map_err(CoreError::Io)?; + writeln!(handle, "mime: {m}").map_err(CoreError::from)?; } if let (Some(w), Some(h)) = (meta.width, meta.height) { - writeln!(handle, "dimensions: {w}x{h}").map_err(CoreError::Io)?; + writeln!(handle, "dimensions: {w}x{h}").map_err(CoreError::from)?; } if let Some(d) = meta.duration_ms { - writeln!(handle, "duration_ms: {d}").map_err(CoreError::Io)?; + writeln!(handle, "duration_ms: {d}").map_err(CoreError::from)?; } if let Some(c) = &meta.captured_at { - writeln!(handle, "captured_at: {c}").map_err(CoreError::Io)?; + writeln!(handle, "captured_at: {c}").map_err(CoreError::from)?; } if let Some(m) = &meta.camera_make { - writeln!(handle, "camera_make: {m}").map_err(CoreError::Io)?; + writeln!(handle, "camera_make: {m}").map_err(CoreError::from)?; } if let Some(m) = &meta.camera_model { - writeln!(handle, "camera_model: {m}").map_err(CoreError::Io)?; + writeln!(handle, "camera_model: {m}").map_err(CoreError::from)?; } if let Some(c) = &meta.codec { - writeln!(handle, "codec: {c}").map_err(CoreError::Io)?; + writeln!(handle, "codec: {c}").map_err(CoreError::from)?; } if let Some(b) = meta.bitrate_bps { - writeln!(handle, "bitrate_bps: {b}").map_err(CoreError::Io)?; + writeln!(handle, "bitrate_bps: {b}").map_err(CoreError::from)?; } Ok(()) } diff --git a/crates/cli/src/cmd/scan.rs b/crates/cli/src/cmd/scan.rs index 35982ab..cd16e87 100644 --- a/crates/cli/src/cmd/scan.rs +++ b/crates/cli/src/cmd/scan.rs @@ -1,31 +1,24 @@ -//! `perima scan` implementation. - -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -use perima_core::{ - BlakeHash, CoreError, DeviceId, DiscoveredFile, FileRepository, HashService, HashedFile, - MediaPath, MetadataExtractor, MetadataRepository, Scanner, UpsertOutcome, VolumeId, - VolumeRepository, -}; -use perima_media::{ - CompositeExtractor, ImageExtractor, MetadataQueue, ThumbnailGenerator, VideoExtractor, -}; -use rayon::prelude::*; +//! `perima scan` implementation — thin CLI delegator to +//! [`perima_app::ScanUseCase`]. +//! +//! The orchestration body (walk → hash → persist → metadata queue) lives in +//! `crates/app/src/scan.rs` (Task 2 landed). This module keeps only: +//! - [`ScanArgs`] — CLI flag bag parsed by clap. +//! - [`ScanStats`] — aggregate counts returned for main.rs's exit-code mapping. +//! - [`ExitCode`] — local enum the CLI dispatcher maps to a POSIX code. +//! - [`OnPersistFactory`] — shell-owned sentinel-migration closure builder. +//! - [`run`] — single ≈40-line delegator. +//! +//! Bodies: imports + clap args + thin dispatcher. No rayon, no `MetadataQueue`, +//! no volume detection, no manifest write — all moved into `ScanUseCase`. + +use std::path::PathBuf; + +use perima_app::{AppContainer, FullScan, OnPersist, ScanCommand, ScanReport}; +use perima_core::{CoreError, DeviceId}; use crate::signals::Cancellation; -/// Maximum time `scan` waits for the metadata worker to drain after the -/// walk loop completes. -/// -/// WHY 30 s: long enough for the typical <10-file corpus the integration -/// tests use to complete comfortably, short enough that Ctrl-C remains -/// responsive (the drain also polls cancel). `--no-wait-metadata` -/// bypasses this when the user wants fast scan exit. -pub(crate) const METADATA_DRAIN_TIMEOUT: Duration = Duration::from_secs(30); - /// Arguments for the scan command. // WHY allow(struct_excessive_bools): each flag corresponds to a // distinct user-facing `--flag` on `perima scan`; converting them into @@ -42,24 +35,12 @@ pub(crate) struct ScanArgs { /// Suppress per-file stdout lines; print summary only. pub quiet: bool, /// Skip the bounded post-walk drain of the metadata queue. - /// - /// WHY opt-in: by default `scan` waits up to - /// [`METADATA_DRAIN_TIMEOUT`] for the in-flight metadata - /// extraction to persist. For very large scans where the user - /// would rather the CLI return immediately and let the queue die - /// with the process, `--no-wait-metadata` bypasses the drain. pub no_wait_metadata: bool, /// Disable WebP thumbnail generation for image/video files. - /// - /// WHY opt-in: thumbnails double the per-file work (decode + encode - /// vs. header-only read). Users who want metadata-only indexing or - /// faster scans can pass `--no-thumbnails`; rows stay at - /// `thumbnail_status = 'pending'` so a future retry command can - /// generate them later. pub no_thumbnails: bool, } -/// Scan statistics. +/// Scan statistics (CLI summary surface). #[derive(Debug, Clone, Copy, Default)] pub(crate) struct ScanStats { /// Files newly indexed. @@ -70,6 +51,16 @@ pub(crate) struct ScanStats { pub errors: u64, } +impl From<&ScanReport> for ScanStats { + fn from(r: &ScanReport) -> Self { + Self { + new: r.files_new, + existing: r.files_updated, + errors: r.files_errored, + } + } +} + /// Exit code returned to `main`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ExitCode { @@ -79,313 +70,96 @@ pub(crate) enum ExitCode { Interrupted, } -/// Callback invoked after each successful file persist: -/// `(relative_path, real_volume_id, device_id)`. +/// Shell-owned factory for the per-file `on_persist` sentinel migration +/// closure. The production dispatcher in `main.rs` opens its own DB +/// connection and constructs a closure that calls +/// `perima_db::SqliteFileRepository::migrate_sentinel_row`; tests pass +/// `None` to bypass sentinel migration entirely. /// -/// WHY type alias: the full `Option<&dyn Fn(...)>` signature trips -/// `clippy::type_complexity`; a named alias keeps the `run` signature readable. -pub(crate) type OnPersistFn<'a> = Option<&'a dyn Fn(&MediaPath, VolumeId, DeviceId)>; +/// WHY a type alias rather than an inline signature: the full +/// `Option` expression triggers `clippy::type_complexity` once +/// it's threaded through a function signature; this alias keeps call sites +/// readable. +pub(crate) type OnPersistFactory = Option; -/// Execute `scan`. -/// -/// When `dry_run` is false, `file_repo` and `volume_repo` must be `Some`; -/// volume detection is performed via [`perima_fs::detect_volume`], the volume -/// is resolved (or created) via [`VolumeRepository::find_or_create`], and each -/// hashed file is persisted via [`FileRepository::upsert_file`] + -/// [`FileRepository::upsert_location`]. After the persist loop, -/// `.perima/manifest.db` is written at the volume root. -/// -/// `on_persist` is an optional callback invoked after each successful location -/// upsert with `(relative_path, real_volume_id, device_id)`. The production -/// caller passes a closure that calls -/// [`perima_db::SqliteFileRepository::migrate_sentinel_row`]; test callers -/// may pass `None`. -/// -/// When `dry_run` is true, pass `None` for all three optional arguments — -/// no DB writes or volume detection occur. +/// Execute `perima scan` by delegating to [`perima_app::ScanUseCase`]. /// -/// If `metadata_repo` is `Some`, successful upserts (`Inserted` or `Updated`) -/// enqueue `(hash, absolute_path)` into a freshly spawned [`MetadataQueue`]. -/// At exit the queue is drained up to [`METADATA_DRAIN_TIMEOUT`] unless -/// `args.no_wait_metadata` is set. Pass `None` (together with `None` for the -/// other repos) in dry-run mode. +/// Per-file output lines remain a CLI concern — the `UseCase` surfaces each +/// file on `ScanReport::per_file_entries` so shells can print whatever +/// columns they like. The aggregate summary line + exit-code mapping stay +/// in the CLI. /// /// # Errors -/// Returns `CoreError::InvalidPath` if `root` is not a directory; -/// propagates `CoreError` from hashing, walking, and volume detection. -// -// WHY `#[allow(clippy::future_not_send)]`: `on_persist` is typed as -// `&dyn Fn(..)` without `Sync`, so the returned future is not `Send`. -// In practice `scan::run` is only ever awaited from the CLI's main -// task — it is never moved across worker threads — so the Send bound -// is an abstract concern, not a real one. Tightening `on_persist` to -// `Sync` would break the existing callers that close over a -// `SqliteFileRepository` (Mutex is Sync, but the closure -// itself captures an immutable borrow that doesn't add work). -// -// WHY `#[allow(clippy::cognitive_complexity)]`: the persist-loop body -// grew a single additional branch for `enqueue` per the plan. Splitting -// it into a helper would require threading half a dozen borrowed -// locals through a signature — worse readability for a lint that -// flags a one-extra-branch nested match. -#[allow(clippy::too_many_arguments)] -#[allow(clippy::too_many_lines)] -#[allow(clippy::future_not_send)] -#[allow(clippy::cognitive_complexity)] -pub(crate) async fn run( - scanner: &S, - hasher: &H, - mut file_repo: Option<&mut FR>, - mut volume_repo: Option<&mut VR>, - metadata_repo: Option>, - thumbnailer: Option>, - on_persist: OnPersistFn<'_>, +/// Propagates [`CoreError`] from the `UseCase` (invalid path, hash failure, +/// persist failure, etc.). `main.rs` maps specific variants to exit codes. +pub(crate) async fn run( + container: &AppContainer, device: DeviceId, cancel: &Cancellation, + on_persist: OnPersistFactory, args: &ScanArgs, -) -> Result<(ExitCode, ScanStats), CoreError> -where - S: Scanner + ?Sized, - H: HashService + ?Sized, - FR: FileRepository + ?Sized, - VR: VolumeRepository + ?Sized, -{ - validate_root(&args.root)?; - - // WHY: canonicalize once, then use the canonical form for BOTH - // the walk root and volume_root. On macOS, tempdir() returns - // /var/folders/... which is a symlink to /private/var/folders/...; - // without canonicalizing the walk root, walkdir produces paths - // under /var/ that fail strip_prefix against /private/var/. - let canonical_root = canonicalize_for_walk(&args.root)?; - let stdout = std::io::stdout(); - let mut stats = ScanStats::default(); - - // Spawn the metadata queue up front (non-dry-run only). WHY at the - // top: the worker should be alive before the first `upsert_file` - // so the very first enqueue never races the `tokio::spawn`. - // - // WHY `Option`: dry-run passes `None` for - // `metadata_repo` and this stays `None` — no worker, no drain. - // - // WHY fall back to `ThumbnailGenerator::disabled()`: the queue - // worker holds the generator unconditionally. `args.no_thumbnails` - // forces `disabled()` regardless of what the caller supplied; a - // `None` caller also collapses to `disabled()`. - let effective_thumbnailer: Arc = if args.no_thumbnails { - Arc::new(ThumbnailGenerator::disabled()) - } else { - thumbnailer - .clone() - .unwrap_or_else(|| Arc::new(ThumbnailGenerator::disabled())) - }; - let mut queue: Option = metadata_repo.as_ref().map(|repo| { - let extractor: Arc = Arc::new(CompositeExtractor::new(vec![ - Arc::new(ImageExtractor::new()) as Arc, - Arc::new(VideoExtractor::new()) as Arc, - ])); - MetadataQueue::spawn( - extractor, - Arc::clone(repo), - Arc::clone(&effective_thumbnailer), - device, - cancel.token(), - ) +) -> Result<(ExitCode, ScanStats), CoreError> { + let cmd = ScanCommand::Full(FullScan { + path: args.root.clone(), + device_id: device, + // WHY `with_metadata = !dry_run`: the CLI historically spawned the + // metadata queue on every non-dry scan. Preserving that default here + // keeps the CI fixtures (`scan_with_metadata_test.rs`) green. + with_metadata: !args.dry_run, + dry_run: args.dry_run, + no_wait_metadata: args.no_wait_metadata, + no_thumbnails: args.no_thumbnails, + cancel: cancel.token(), + on_persist, }); - // Resolve volume once before the scan loop (no-op in dry-run). - // WHY: detect+find_or_create happen here, outside the per-file loop, so - // the volume repo connection is not held across rayon's parallel hash phase. - let volume_info: Option<(VolumeId, String, PathBuf)> = if args.dry_run { - None - } else { - let detected = perima_fs::detect_volume(&canonical_root)?; - let label = detected - .identifiers - .label - .clone() - .unwrap_or_else(|| "unknown".to_owned()); - let vol_id = volume_repo - .as_mut() - .ok_or_else(|| CoreError::Internal("volume_repo is None in live scan".into()))? - .find_or_create(&detected.identifiers, device)?; - volume_repo - .as_mut() - .expect("volume_repo checked above") - .record_mount(vol_id, device, &detected.mount_point)?; - Some((vol_id, label, detected.mount_point)) - }; - - // Collect up-front so rayon can parallelize hashing; the walker - // iterator itself isn't Send across the par_iter boundary. The - // inner `take_while` polls between yielded items so a Ctrl-C - // during walk short-circuits quickly. - let discovered: Vec = scanner - .walk(&canonical_root, &canonical_root)? - .take_while(|_| !cancel.cancelled()) - .collect(); - - // Parallel hash. WHY: we also check cancellation at the top of - // each map closure so in-flight hashes short-circuit the moment - // Ctrl-C lands — without this, a large fixture would drain the - // par_iter to completion even after the flag flips, defeating - // the "Ctrl-C stops hashing" guarantee in the spec. - // - // WHY clone: CancellationToken is Arc-backed; clone is O(1) and - // shares state with the original. The clone is moved into the - // rayon closure which requires 'static + Send. - let cancel_token = cancel.token(); - let results: Vec> = discovered - .into_par_iter() - .map(|d| { - if cancel_token.is_cancelled() { - return Err(CoreError::Internal("cancelled".into())); - } - let h = hasher.full_hash(&d.absolute_path)?; - Ok((d, h)) - }) - .collect(); - - // Collect successfully persisted files for the manifest write after the loop. - let mut manifest_files: Vec = Vec::new(); - - let mut handle = stdout.lock(); - for res in results { - match res { - Ok((d, h)) => { - if !args.quiet { - writeln!( - handle, - "{} {} {}", - h.to_hex(), - d.size.0, - d.relative_path.as_str() - ) - .map_err(CoreError::Io)?; - } - if let Some(ref mut fr) = file_repo { - let volume = volume_info - .as_ref() - .map_or_else(|| VolumeId(uuid::Uuid::nil()), |(v, _, _)| *v); - match persist_file(*fr, &d, &h, device, volume) { - Ok(outcome) => { - // WHY: sentinel migration runs per-file, scoped to - // (relative_path, sentinel volume_id, deleted_at IS NULL). - // Running it right after a successful upsert confirms - // the file still exists on disk before we reattribute - // its old row to the real volume. - if let Some(cb) = on_persist { - cb(&d.relative_path, volume, device); - } - // WHY enqueue only on Inserted|Updated (not - // Unchanged): Unchanged means the scanner has - // already persisted this hash with identical - // metadata on a prior scan — re-extracting - // would do identical work. If the user wants - // a forced re-extract they can call - // `perima metadata `. - if matches!(outcome, UpsertOutcome::Inserted | UpsertOutcome::Updated) - && let Some(q) = queue.as_ref() - && let Err(e) = - q.enqueue(h, d.absolute_path.clone(), &cancel.token()) - { - // WHY log + continue: the plan is - // explicit that a metadata-queue - // failure must not abort the scan. - // The user can always re-run or - // `perima metadata` for stragglers. - tracing::warn!( - error = %e, - path = %d.absolute_path.display(), - "metadata enqueue failed; continuing scan", - ); - } - manifest_files.push(HashedFile { - discovered: d, - hash: h, - }); - match outcome { - UpsertOutcome::Inserted => stats.new += 1, - UpsertOutcome::Updated | UpsertOutcome::Unchanged => { - stats.existing += 1; - } - } - } - Err(e) => { - tracing::warn!(error = %e, "persist failed"); - stats.errors += 1; - } - } - } else { - // Dry-run: count every successfully hashed file as new - // so the summary total is accurate. - stats.new += 1; - } - } - Err(e) => { - tracing::warn!(error = %e, "skipping file: hash failed"); - stats.errors += 1; - } + let report = container.scan.execute(cmd).await?; + + // WHY: per-file output stays in the CLI — it's a shell presentation + // concern, not a UseCase output. `ScanReport::per_file_entries` is + // populated for exactly this purpose. + if !args.quiet { + use std::io::Write; + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + for entry in &report.per_file_entries { + writeln!( + handle, + "{} {} {}", + entry.hash.to_hex(), + entry.size, + entry.relative_path.as_str() + ) + .map_err(CoreError::from)?; } } - drop(handle); - - // Write manifest after the persist loop (non-dry-run only). - if let Some((vol_id, _, ref mount_point)) = volume_info { - perima_db::manifest::write_manifest(mount_point, vol_id, &manifest_files)?; - } - // Bounded drain of the metadata queue. - // - // WHY drop-then-await: dropping the `MetadataQueue` closes the - // `Sender` half of the channel; the worker's `rx.recv()` returns - // `None` once the buffer is empty and the worker exits cleanly. - // Awaiting the `JoinHandle` with a timeout bounds the wait so a - // stuck extractor cannot hang the CLI. - // - // WHY `--no-wait-metadata` bypasses by dropping the queue without - // awaiting: users who scripted `perima scan` around v0.3's - // fire-and-forget exit semantics can opt out; stragglers fall off - // the tokio runtime when `main` returns. - if let Some(mut q) = queue.take() { - if args.no_wait_metadata { - drop(q); - } else { - let worker = q.take_worker(); - drop(q); - if let Some(handle) = worker { - match tokio::time::timeout(METADATA_DRAIN_TIMEOUT, handle).await { - Ok(Ok(())) => { - tracing::debug!("metadata queue drained cleanly"); - } - Ok(Err(e)) => { - tracing::warn!(error = %e, "metadata worker join failed"); - } - Err(_) => { - tracing::warn!( - "metadata queue did not drain within {METADATA_DRAIN_TIMEOUT:?}; \ - re-run `perima scan` or `perima metadata ` for stragglers", - ); - } - } - } - } + // WHY manifest write stays in CLI: the UseCase deliberately does NOT + // depend on `perima-db` (spec §2 IN). The report exposes the volume + // mount + manifest files so shells can call `manifest::write_manifest`. + if let Some((vol_id, mount)) = report.volume_mount.as_ref() { + perima_db::manifest::write_manifest(mount, *vol_id, &report.manifest_files)?; } - let interrupted = cancel.cancelled(); - let suffix = if interrupted { " (interrupted)" } else { "" }; + let stats = ScanStats::from(&report); + let suffix = if report.interrupted { + " (interrupted)" + } else { + "" + }; if args.dry_run { let total = stats.new + stats.existing + stats.errors; eprintln!("scanned {total} files (dry-run; DB not wired){suffix}"); } else { - let label_or_id = volume_info.as_ref().map_or_else( + let label_or_id = report.volume_mount.as_ref().map_or_else( || "?".to_owned(), - |(vol_id, label, _)| { + |(vol_id, _)| { + let label = report.volume_label.as_deref().unwrap_or("unknown"); if label == "unknown" || label.is_empty() { let s = vol_id.0.to_string(); s[..8].to_owned() } else { - label.clone() + label.to_owned() } }, ); @@ -399,7 +173,7 @@ where } Ok(( - if interrupted { + if report.interrupted { ExitCode::Interrupted } else { ExitCode::Success @@ -407,43 +181,3 @@ where stats, )) } - -/// Persist a single hashed file: upsert the content record, then the -/// location record. Returns the location outcome so the caller can -/// classify the result as new/existing. -fn persist_file( - repo: &R, - d: &DiscoveredFile, - h: &BlakeHash, - device: DeviceId, - volume: VolumeId, -) -> Result { - let hf = perima_core::HashedFile { - discovered: d.clone(), - hash: *h, - }; - repo.upsert_file(&hf, device)?; - repo.upsert_location(h, volume, &d.relative_path, device) -} - -fn validate_root(root: &Path) -> Result<(), CoreError> { - if !root.exists() { - return Err(CoreError::InvalidPath(format!( - "does not exist: {}", - root.display() - ))); - } - if !root.is_dir() { - return Err(CoreError::InvalidPath(format!( - "not a directory: {}", - root.display() - ))); - } - Ok(()) -} - -fn canonicalize_for_walk(root: &Path) -> Result { - // WHY: routes through perima_fs::platform_path::canonicalize — the single - // source of truth for the #[cfg(windows)] dunce / std fallback. - perima_fs::platform_path::canonicalize(root).map_err(CoreError::Io) -} diff --git a/crates/cli/src/cmd/search.rs b/crates/cli/src/cmd/search.rs index 3490268..8d40bc4 100644 --- a/crates/cli/src/cmd/search.rs +++ b/crates/cli/src/cmd/search.rs @@ -1,6 +1,7 @@ -//! `perima search` subcommand — full-text search over indexed metadata. +//! `perima search` subcommand — thin delegator to [`perima_app::SearchUseCase`]. -use perima_core::{CoreError, SearchRepository}; +use perima_app::{AppContainer, SearchCommand}; +use perima_core::CoreError; /// Arguments for the `perima search` command. #[derive(clap::Args, Debug)] @@ -33,24 +34,33 @@ pub(crate) struct SearchArgs { /// # Errors /// Returns [`CoreError::Internal`] on DB/`FTS5` errors. /// Returns [`CoreError::Unsupported`] when no query is supplied without `--rebuild`. -pub(crate) fn run(repo: &S, args: &SearchArgs) -> Result<(), CoreError> -where - S: SearchRepository + ?Sized, -{ +pub(crate) async fn run(container: &AppContainer, args: &SearchArgs) -> Result<(), CoreError> { if args.rebuild { - repo.rebuild()?; + container.search.execute(SearchCommand::Rebuild).await?; eprintln!("perima: search index rebuilt"); return Ok(()); } - let query = args + // WHY the empty-query check + unwrap stays here: clap's + // `required_unless_present = "rebuild"` already guarantees `query` is + // `Some(...)` once we're past the rebuild branch. The UseCase ALSO + // rejects empty/whitespace-only queries with `CoreError::Unsupported`, + // so we just delegate. + let q = args .query .as_deref() - .filter(|q| !q.trim().is_empty()) - .ok_or_else(|| CoreError::Unsupported("query must be non-empty".into()))?; + .ok_or_else(|| CoreError::Unsupported("query must be non-empty".into()))? + .to_owned(); - let hits = repo.search(query, args.limit)?; + let out = container + .search + .execute(SearchCommand::Query { + q, + limit: Some(args.limit), + }) + .await?; + let hits = out.hits; if args.json { let json = serde_json::to_string(&hits) .map_err(|e| CoreError::Internal(format!("json serialize: {e}")))?; diff --git a/crates/cli/src/cmd/tag.rs b/crates/cli/src/cmd/tag.rs index 76b7276..0393c29 100644 --- a/crates/cli/src/cmd/tag.rs +++ b/crates/cli/src/cmd/tag.rs @@ -1,4 +1,4 @@ -//! `perima tag` subcommand — add, remove, and list tags. +//! `perima tag` subcommand — thin delegator to [`perima_app::TagUseCase`]. //! //! Provides three sub-subcommands: //! - `tag add ` — attach one or more labels to a file @@ -8,7 +8,8 @@ use std::io::Write; use std::path::{Path, PathBuf}; -use perima_core::{BlakeHash, CoreError, DeviceId, FileRepository, TagRepository, normalize_tag}; +use perima_app::{AppContainer, TagCommand, TagOutput}; +use perima_core::{BlakeHash, CoreError, DeviceId, normalize_tag}; use super::metadata::find_by_absolute_suffix; @@ -28,12 +29,6 @@ pub(crate) enum TagAction { /// Path to the file to tag. path: PathBuf, /// One or more tag names to attach. - /// - /// WHY required + 1..: clap's default for `Vec` is "zero or - /// more positionals" — `perima tag add foo.jpg` would silently - /// no-op. Force at least one tag argument so the error is a - /// clear "missing required tag" message instead of success- - /// that-did-nothing. #[arg(required = true, num_args = 1..)] tags: Vec, }, @@ -58,32 +53,26 @@ pub(crate) enum TagAction { /// Returns [`CoreError::InvalidPath`] when the file does not exist or /// is not yet indexed (run `perima scan` first); propagates /// [`CoreError`] from tag normalization, DB access, and I/O. -pub(crate) fn run( - tag_repo: &T, - file_repo: &F, +pub(crate) async fn run( + container: &AppContainer, + data_dir: &Path, device: DeviceId, args: &TagArgs, -) -> Result<(), CoreError> -where - T: TagRepository + ?Sized, - F: FileRepository + ?Sized, -{ +) -> Result<(), CoreError> { match &args.action { - TagAction::Add { path, tags } => run_add(tag_repo, file_repo, device, path, tags), - TagAction::Rm { path, tag } => run_rm(tag_repo, file_repo, device, path, tag), - TagAction::Ls { json } => run_ls(tag_repo, *json), + TagAction::Add { path, tags } => run_add(container, data_dir, device, path, tags).await, + TagAction::Rm { path, tag } => run_rm(container, data_dir, device, path, tag).await, + TagAction::Ls { json } => run_ls(container, data_dir, *json).await, } } -/// Resolve a path to a `BlakeHash` by suffix-matching indexed locations. +/// Resolve a path to a `BlakeHash` via the container's shared file repository. /// -/// WHY separate helper: both `run_add` and `run_rm` need the same -/// canonicalization + suffix-match logic. Extracting it avoids -/// duplicating the error messages and the `list_file_locations` call. -fn resolve_hash(file_repo: &F, path: &Path) -> Result -where - F: FileRepository + ?Sized, -{ +/// WHY `container` instead of a local open: the writer actor owns the sole +/// writable connection; opening a second one here would require a second +/// writer. `container.files_repo` is the shared `Arc` +/// already wired to the writer+pool. +fn resolve_hash(container: &AppContainer, path: &Path) -> Result { if !path.exists() { return Err(CoreError::InvalidPath(format!( "does not exist: {}", @@ -97,16 +86,15 @@ where ))); } - let absolute = perima_fs::platform_path::canonicalize(path).map_err(CoreError::Io)?; + let absolute = perima_fs::platform_path::canonicalize(path).map_err(CoreError::from)?; let absolute_str = absolute .to_str() .ok_or_else(|| CoreError::InvalidPath(format!("non-UTF8 path: {}", absolute.display())))?; // WHY list across ALL volumes (None): the user supplies an absolute - // path that may live on any known volume. We don't know which scan - // root produced the relative-path record, so suffix-matching against - // the full location set is the only portable approach. - let records = file_repo.list_file_locations(usize::MAX, None)?; + // path that may live on any known volume. Suffix-matching across the + // full location set is the only portable approach. + let records = container.files_repo.list_file_locations(usize::MAX, None)?; let record = find_by_absolute_suffix(&records, absolute_str).ok_or_else(|| { CoreError::InvalidPath(format!( "not indexed: {} (run `perima scan` first)", @@ -118,24 +106,31 @@ where } /// Attach one or more tags to a file. -fn run_add( - tag_repo: &T, - file_repo: &F, +async fn run_add( + container: &AppContainer, + _data_dir: &Path, device: DeviceId, path: &Path, tags: &[String], -) -> Result<(), CoreError> -where - T: TagRepository + ?Sized, - F: FileRepository + ?Sized, -{ - let hash = resolve_hash(file_repo, path)?; +) -> Result<(), CoreError> { + let hash = resolve_hash(container, path)?; let mut applied = Vec::with_capacity(tags.len()); for raw in tags { - let tag = tag_repo.upsert_tag(raw, device)?; - tag_repo.attach(&hash, tag.id, device)?; - applied.push(tag.name); + container + .tag + .execute(TagCommand::Attach { + hash, + name: raw.clone(), + device, + }) + .await?; + // WHY call normalize_tag for the message: the UseCase does the + // same normalization internally; doing it here keeps the user- + // visible confirmation line consistent with the actual stored + // name when the raw input carried whitespace/case variance. + let normalized = normalize_tag(raw)?; + applied.push(normalized); } let stdout = std::io::stdout(); @@ -146,54 +141,54 @@ where path.display(), applied.join(", ") ) - .map_err(CoreError::Io) + .map_err(CoreError::from) } /// Remove a tag from a file. -fn run_rm( - tag_repo: &T, - file_repo: &F, +async fn run_rm( + container: &AppContainer, + _data_dir: &Path, device: DeviceId, path: &Path, tag_raw: &str, -) -> Result<(), CoreError> -where - T: TagRepository + ?Sized, - F: FileRepository + ?Sized, -{ - let hash = resolve_hash(file_repo, path)?; - - // WHY upsert_tag for rm: we need the tag's UUID to call detach. - // upsert_tag is idempotent — if the tag doesn't exist we create it - // (harmless), then detach finds no active row (no-op soft-delete). - let tag = tag_repo.upsert_tag(tag_raw, device)?; - tag_repo.detach(&hash, tag.id, device)?; +) -> Result<(), CoreError> { + let hash = resolve_hash(container, path)?; + + container + .tag + .execute(TagCommand::Detach { + hash, + name: tag_raw.to_owned(), + device, + }) + .await?; let stdout = std::io::stdout(); let mut handle = stdout.lock(); let normalized = normalize_tag(tag_raw)?; - writeln!(handle, "removed {} from {}", normalized, path.display()).map_err(CoreError::Io) + writeln!(handle, "removed {} from {}", normalized, path.display()).map_err(CoreError::from) } /// List all active tags with their per-tag file counts. -fn run_ls(tag_repo: &T, json: bool) -> Result<(), CoreError> -where - T: TagRepository + ?Sized, -{ - let tags = tag_repo.list_tags()?; - - // WHY pre-compute counts: propagate DB errors via `?` instead of - // swallowing them with `unwrap_or(0)` — a mutex-poison or SQLite - // failure should surface, not produce silent zero-counts. +async fn run_ls(container: &AppContainer, _data_dir: &Path, json: bool) -> Result<(), CoreError> { + let out = container.tag.execute(TagCommand::List).await?; + let TagOutput::Tags(tags) = out else { + return Err(CoreError::Internal( + "TagCommand::List returned non-Tags output".into(), + )); + }; + + // WHY direct TagRepository via container.tags: `count_files_for_tag` + // is not (yet) exposed through `TagUseCase`. Post-Batch-C Task 3, + // the container exposes `Arc` directly so we no + // longer need a short-lived `SqliteTagRepository::new(...)` open + // here. A future UseCase iteration can lift this into `TagOutput`. let counts: Vec = tags .iter() - .map(|t| tag_repo.count_files_for_tag(t.id)) + .map(|t| container.tags.count_files_for_tag(t.id)) .collect::>()?; if json { - // WHY manual construction instead of deriving Serialize on Tag: - // Tag already derives Serialize but we want a flat `{name, count, id}` - // shape rather than Tag's `{id, name, first_seen}` shape. let rows: Vec = tags .iter() .zip(&counts) @@ -210,13 +205,13 @@ where let mut handle = stdout.lock(); serde_json::to_writer_pretty(&mut handle, &rows) .map_err(|e| CoreError::Internal(format!("json: {e}")))?; - writeln!(handle).map_err(CoreError::Io)?; + writeln!(handle).map_err(CoreError::from)?; } else { let stdout = std::io::stdout(); let mut handle = stdout.lock(); - writeln!(handle, "{:<32} {:>6} ID", "NAME", "COUNT").map_err(CoreError::Io)?; + writeln!(handle, "{:<32} {:>6} ID", "NAME", "COUNT").map_err(CoreError::from)?; for (t, &count) in tags.iter().zip(&counts) { - writeln!(handle, "{:<32} {:>6} {}", t.name, count, t.id).map_err(CoreError::Io)?; + writeln!(handle, "{:<32} {:>6} {}", t.name, count, t.id).map_err(CoreError::from)?; } } diff --git a/crates/cli/src/cmd/volumes.rs b/crates/cli/src/cmd/volumes.rs index 1b43e9a..01b273c 100644 --- a/crates/cli/src/cmd/volumes.rs +++ b/crates/cli/src/cmd/volumes.rs @@ -1,8 +1,9 @@ -//! `perima volumes` implementation. +//! `perima volumes` — thin delegator to [`perima_app::VolumeUseCase`]. use std::io::Write; -use perima_core::{CoreError, DeviceId, VolumeRepository}; +use perima_app::{AppContainer, VolumeCommand, VolumeOutput}; +use perima_core::{CoreError, DeviceId}; use super::format::format_size; @@ -13,9 +14,18 @@ use super::format::format_size; /// any mount paths seen on this machine. /// /// # Errors -/// Propagates `CoreError` from the repository. -pub(crate) fn run(repo: &VR, machine: DeviceId) -> Result<(), CoreError> { - let records = repo.list(machine)?; +/// Propagates `CoreError` from the `UseCase` / repository. +pub(crate) async fn run(container: &AppContainer, machine: DeviceId) -> Result<(), CoreError> { + let out = container + .volume + .execute(VolumeCommand::List { device: machine }) + .await?; + + let VolumeOutput::Volumes(records) = out else { + return Err(CoreError::Internal( + "VolumeCommand::List returned non-Volumes output".into(), + )); + }; let stdout = std::io::stdout(); let mut handle = stdout.lock(); @@ -25,7 +35,7 @@ pub(crate) fn run(repo: &VR, machine: DeviceId) -> Result< "{:<10} {:<16} {:<9} {:<10} MOUNT PATHS", "VOLUME ID", "LABEL", "REMOVABLE", "CAPACITY", ) - .map_err(CoreError::Io)?; + .map_err(CoreError::from)?; for r in &records { let vol_str = r.id.0.to_string(); @@ -46,7 +56,7 @@ pub(crate) fn run(repo: &VR, machine: DeviceId) -> Result< handle, "{vol_short:<10} {label:<16} {removable:<9} {capacity:<10} {mounts}", ) - .map_err(CoreError::Io)?; + .map_err(CoreError::from)?; } Ok(()) diff --git a/crates/cli/src/cmd/watch.rs b/crates/cli/src/cmd/watch.rs index 0406de0..7b06a06 100644 --- a/crates/cli/src/cmd/watch.rs +++ b/crates/cli/src/cmd/watch.rs @@ -6,73 +6,60 @@ //! - [`FileEvent::Modified`] → `status = stale`. //! - [`FileEvent::Deleted`] → `status = missing`. //! - [`FileEvent::Renamed`] → rename the location row, reset to active. +//! +//! WHY `run` consumes `container.events` directly: `main.rs::dispatch_watch` +//! constructs a [`DbEventHandler`] via [`make_db_event_handler`] and passes +//! it as an `extra_handler` to `build_container` before `AppContainer::new` +//! wraps all handlers in the single [`perima_app::Bus`]. +//! `run` then receives `container.events` — the already-composed bus — +//! and forwards it directly to `DebouncedWatcher`. No second bus +//! construction happens in the shell layer (resolves spec §4 acceptance). use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; -use perima_core::{CoreError, DeviceId, EventBus, FileEvent, LocationStatus, VolumeRepository}; -use perima_db::{SqliteFileRepository, SqliteVolumeRepository, open_and_migrate}; +use perima_app::{AppContainer, EventHandler}; +use perima_core::{AppEvent, CoreError, DeviceId, FileEvent, LocationStatus}; +use perima_db::SqliteFileRepository; use perima_fs::DebouncedWatcher; use crate::signals::Cancellation; // --------------------------------------------------------------------------- -// CompositeEventBus +// DbEventHandler // --------------------------------------------------------------------------- -/// Fans out events to multiple [`EventBus`] implementations. -/// -/// Individual handler errors are logged but do not abort the fan-out — -/// all registered handlers always fire regardless of prior failures. +/// Updates the database in response to filesystem events. /// -/// WHY lives in watch.rs (not core): `CompositeEventBus` uses `tracing::warn!` -/// which requires the `tracing` crate. `crates/core` deliberately has zero -/// framework dependencies, so the composite lives in the CLI shell where -/// `tracing` is already a direct dependency. -pub(crate) struct CompositeEventBus { - handlers: Vec>, +/// WHY `Arc`: `EventHandler` requires `Send + 'static`. +/// `SqliteFileRepository` uses interior mutability (flume sender + r2d2 pool), +/// satisfying both. `Arc` gives shared ownership without cloning the heavy repo. +struct DbEventHandler { + repo: Arc, + device: DeviceId, } -impl CompositeEventBus { - /// Construct from a list of handlers. - #[must_use] - pub(crate) fn new(handlers: Vec>) -> Self { - Self { handlers } +#[async_trait::async_trait] +impl EventHandler for DbEventHandler { + fn name(&self) -> &'static str { + "db_event_handler" } -} -impl EventBus for CompositeEventBus { - fn emit(&self, event: &FileEvent) -> Result<(), CoreError> { - for h in &self.handlers { - if let Err(e) = h.emit(event) { - tracing::warn!(error = %e, "event handler failed"); - } + async fn handle(&mut self, event: AppEvent) { + // CLI watch handler only acts on FileEvents — domain events + // (ScanCompleted, IndexInvalidated) are no-ops since the CLI + // has no frontend cache to invalidate. + if let AppEvent::File(file_event) = event + && let Err(e) = self.record_file_event(&file_event) + { + tracing::warn!(error = %e, "failed to record file event"); } - Ok(()) } } -// --------------------------------------------------------------------------- -// DbEventHandler -// --------------------------------------------------------------------------- - -/// Updates the database in response to filesystem events. -/// -/// WHY `Arc`: `EventBus` requires `Send + Sync`. -/// `SqliteFileRepository` uses `Mutex` internally, satisfying both. -/// `Arc` lets `DbEventHandler` be cheaply cloneable and placed in a composite. -/// -/// WHY no `volume` field: every [`FileEvent`] variant carries its own -/// `volume: VolumeId` derived from the watcher's configured volume. We use the -/// event's volume directly rather than shadowing it with a stored copy. -struct DbEventHandler { - repo: Arc, - device: DeviceId, -} - -impl EventBus for DbEventHandler { - fn emit(&self, event: &FileEvent) -> Result<(), CoreError> { +impl DbEventHandler { + fn record_file_event(&self, event: &FileEvent) -> Result<(), CoreError> { match event { FileEvent::Created { path, .. } => { // WHY: we do not hash new files in watch mode. Hashing requires @@ -128,18 +115,18 @@ impl EventBus for DbEventHandler { } } -// --------------------------------------------------------------------------- -// LogEventHandler -// --------------------------------------------------------------------------- - -/// Logs every filesystem event at INFO level. -struct LogEventHandler; - -impl EventBus for LogEventHandler { - fn emit(&self, event: &FileEvent) -> Result<(), CoreError> { - tracing::info!(?event, "file event"); - Ok(()) - } +/// Construct a [`DbEventHandler`] boxed as `Box`. +/// +/// WHY `pub(crate)`: `main.rs::dispatch_watch` builds this handler +/// before calling `build_container`, so it can pass it as an extra +/// handler and `AppContainer::new` absorbs it into the single [`perima_app::Bus`]. +/// Only the watch dispatcher needs this — keeping it `pub(crate)` limits +/// the API surface. +pub(crate) fn make_db_event_handler( + repo: Arc, + device: DeviceId, +) -> Box { + Box::new(DbEventHandler { repo, device }) } // --------------------------------------------------------------------------- @@ -165,7 +152,7 @@ fn validate_root(root: &Path) -> Result<(), CoreError> { fn canonicalize(root: &Path) -> Result { // WHY: routes through perima_fs::platform_path::canonicalize — the single // source of truth for the #[cfg(windows)] dunce / std fallback. - perima_fs::platform_path::canonicalize(root).map_err(CoreError::Io) + perima_fs::platform_path::canonicalize(root).map_err(CoreError::from) } // --------------------------------------------------------------------------- @@ -174,14 +161,17 @@ fn canonicalize(root: &Path) -> Result { /// Run the `watch` subcommand. /// -/// Opens the database, detects the volume for `root`, then starts a -/// [`DebouncedWatcher`] that emits status updates for every filesystem event. +/// Detects the volume for `root`, then starts a [`DebouncedWatcher`] that +/// forwards every filesystem event to `container.events` — the shared bus +/// already wired with the `DbEventHandler` and `LogEventHandler` by +/// `main.rs::dispatch_watch` before this call. /// Blocks until the cancellation token fires (Ctrl-C). /// /// # Errors /// Returns [`CoreError::InvalidPath`] if `root` is not an existing directory; /// propagates [`CoreError`] from volume detection, DB access, or watcher init. pub(crate) async fn run( + container: &AppContainer, data_dir: &Path, device_id: DeviceId, root: &Path, @@ -191,43 +181,32 @@ pub(crate) async fn run( let canonical_root = canonicalize(root)?; let detected = perima_fs::detect_volume(&canonical_root)?; - let db_path = data_dir.join("perima.db"); - - // WHY two connections: SqliteVolumeRepository and SqliteFileRepository each - // take an owned Connection. Under WAL mode a second open is instant and - // allows both repos to operate without a shared connection mutex. - let vol_conn = open_and_migrate(&db_path)?; - let file_conn = open_and_migrate(&db_path)?; - - let vol_repo = SqliteVolumeRepository::new(vol_conn); - let volume_id = vol_repo.find_or_create(&detected.identifiers, device_id)?; - vol_repo.record_mount(volume_id, device_id, &detected.mount_point)?; - drop(vol_repo); - - let file_repo = Arc::new(SqliteFileRepository::new(file_conn)); - - let db_handler = Arc::new(DbEventHandler { - repo: Arc::clone(&file_repo), - device: device_id, - }); - let log_handler = Arc::new(LogEventHandler); - - let composite = CompositeEventBus::new(vec![ - db_handler as Arc, - log_handler as Arc, - ]); + let _ = data_dir; // WHY: kept for signature stability; no raw DB open remains post-Batch-C Task 2. + + // WHY delegate to `container.volumes` post-Batch-C Task 2: the + // writer-actor + read-pool adapter is already built inside the + // container. `find_or_create` has no UseCase surface (scan/watch + // startup concern); the `Arc` field on + // `AppContainer` is the supported shell entry point. + let volume_id = container + .volumes + .find_or_create(&detected.identifiers, device_id)?; + container + .volumes + .record_mount(volume_id, device_id, &detected.mount_point)?; + + // WHY Arc::clone: DebouncedWatcher requires an owned Arc. + // container.events is already the composed fan-out bus (DbEventHandler + + // LogEventHandler), so we clone the Arc without constructing a new bus. + let bus = Arc::clone(&container.events); // WHY 1 s production debounce: short enough for responsive feedback, long // enough to coalesce rapid saves (e.g. editors that write-then-chmod). - // WHY `watcher` (not `_watcher`): the binding must stay live until after - // `cancelled().await` so the underlying OS watch registration persists for - // the full watch session. Using a plain name avoids the - // `clippy::used_underscore_binding` warning when we explicitly drop it. let watcher = DebouncedWatcher::start( std::slice::from_ref(&canonical_root), &canonical_root, volume_id, - Arc::new(composite), + bus, cancel.token(), Duration::from_secs(1), )?; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 64bf983..a3e36d1 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -17,15 +17,19 @@ mod logging; mod panic; mod signals; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::sync::Arc; use clap::{Parser, Subcommand}; -use perima_core::MetadataRepository; +use perima_app::{AppContainer, AppDeps, EventHandler}; +use perima_core::{ + FileRepository, HashService, MetadataRepository, Scanner, SearchRepository, TagRepository, + VolumeRepository, +}; use perima_db::{ - SqliteFileRepository, SqliteMetadataRepository, SqliteSearchRepository, SqliteTagRepository, - SqliteVolumeRepository, open_and_migrate, + ReadPool, SqliteFileRepository, SqliteMetadataRepository, SqliteSearchRepository, + SqliteTagRepository, SqliteVolumeRepository, SqliteWriter, }; use perima_fs::WalkdirScanner; use perima_hash::Blake3Service; @@ -127,10 +131,7 @@ enum Command { }, } -/// Entry point. Tokio runtime is required for the `watch` command (phase 3a) -/// and for `CancellationToken::cancelled().await` in future sub-commands. -/// All existing sync commands (`scan`, `ls`, `volumes`) run directly on the -/// main task without blocking — they complete before yielding. +/// Entry point. /// /// WHY `#[tokio::main]`: `CancellationToken::cancelled()` is an async future; /// we need a runtime even when the current command is sync. The cost is one @@ -187,13 +188,13 @@ async fn main() -> ExitCode { json, with_metadata, tag, - } => dispatch_ls(volume, limit, json, with_metadata, tag, &config), + } => dispatch_ls(volume, limit, json, with_metadata, tag, &config).await, - Command::Tag(args) => dispatch_tag(&args, &config), + Command::Tag(args) => dispatch_tag(&args, &config).await, - Command::Search(args) => dispatch_search(&args, &config), + Command::Search(args) => dispatch_search(&args, &config).await, - Command::Volumes => dispatch_volumes(&config), + Command::Volumes => dispatch_volumes(&config).await, Command::Watch { root } => dispatch_watch(root, &config, &cancel).await, @@ -201,16 +202,138 @@ async fn main() -> ExitCode { } } +// --------------------------------------------------------------------------- +// AppContainer construction +// --------------------------------------------------------------------------- + +/// Build an [`AppContainer`] for a given database path. +/// +/// `extra_handlers` lets callers inject additional [`EventHandler`] implementations +/// before the single [`perima_app::Bus`] is constructed inside +/// [`AppContainer::new`]. The `watch` dispatcher uses this to inject its +/// `DbEventHandler` so that filesystem events can mutate location rows via the +/// shared bus without constructing a second bus in the shell. +fn build_container( + db_path: &Path, + extra_handlers: Vec>, +) -> Result, perima_core::CoreError> { + // WHY a `NoopBus` passed to the writer: the writer's after-COMMIT + // emission path is scaffolded but file-event emission is handled by + // the async-broadcast Bus wired into `AppContainer`. The writer bus + // is separate from the handler list — it receives post-COMMIT events + // from the writer thread (std::thread, not tokio), so it must be + // Arc (sync emit). Spec §§3.3 + 4.8 (A4.8 first bullet). + struct NoopBus; + impl perima_core::events::EventBus for NoopBus { + fn emit(&self, _: &perima_core::AppEvent) -> Result<(), perima_core::CoreError> { + Ok(()) + } + } + let writer_bus: Arc = Arc::new(NoopBus); + + let writer = SqliteWriter::start(db_path, writer_bus)?; + let reads = ReadPool::open(db_path)?; + + // WHY clone `reads` for each adapter: `ReadPool` is cheap to + // [`Clone`] (inner `r2d2::Pool` is `Arc`-backed). + let files: Arc = + Arc::new(SqliteFileRepository::new(writer.sender(), reads.clone())); + let volumes: Arc = + Arc::new(SqliteVolumeRepository::new(writer.sender(), reads.clone())); + let tags: Arc = + Arc::new(SqliteTagRepository::new(writer.sender(), reads.clone())); + let metadata: Arc = Arc::new(SqliteMetadataRepository::new( + writer.sender(), + reads.clone(), + )); + let search: Arc = + Arc::new(SqliteSearchRepository::new(writer.sender(), reads)); + let hasher: Arc = Arc::new(Blake3Service::new()); + let scanner: Arc = Arc::new(WalkdirScanner::new()); + // WHY no explicit `writer` keep-alive: every adapter above holds a + // cloned `flume::Sender` via `writer.sender()`. When + // `build_container` returns, the local `writer` handle drops, but the + // sender clones inside the repos keep the writer thread running for + // the container's lifetime. At CLI process exit, all senders drop and + // the thread observes `Disconnected` + returns. + + // WHY the thumbnailer is chosen at container-build time: the container + // is constructed once per command dispatch (via `build_container`), + // and each dispatcher overrides the thumbnailer *flag* via the + // `FullScan { no_thumbnails }` field at UseCase call time. The wired + // generator here stays enabled by default; `--no-thumbnails` flips + // to `disabled()` inside the UseCase. + let thumbnailer = Arc::new(ThumbnailGenerator::new( + db_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(), + )); + + let deps = AppDeps { + files, + volumes, + tags, + metadata, + search, + hasher, + scanner, + thumbnailer, + }; + + // WHY log handler always first: every command benefits from tracing + // event emissions; `extra_handlers` (injected by the watch dispatcher) + // are appended after so the log entry always fires before DB writes. + let log_handler: Box = Box::new(perima_app::LogEventHandler); + let mut handlers: Vec> = vec![log_handler]; + handlers.extend(extra_handlers); + Ok(AppContainer::new(deps, handlers)) +} + +/// Build a `DbEventHandler` for the `watch` command, boxed as `Box`. +/// +/// WHY a dedicated helper: `dispatch_watch` must construct the handler +/// before calling `build_container` so it can be passed as an `extra_handler`. +/// Extracting it here keeps `dispatch_watch` focused on control-flow and makes +/// the single-connection justification easy to find. +/// +/// WHY a fresh writer+pool pair here: `dispatch_watch` builds the +/// `DbEventHandler` BEFORE calling `build_container`, so the handler's +/// `SqliteFileRepository` must own its own sender. Both senders ride +/// into the Bus via `extra_handlers` and the container respectively — +/// the writer thread keeps running while any sender lives. +fn build_watch_db_handler( + db_path: &Path, + device_id: perima_core::DeviceId, +) -> Result, perima_core::CoreError> { + struct NoopBus; + impl perima_core::events::EventBus for NoopBus { + fn emit(&self, _: &perima_core::AppEvent) -> Result<(), perima_core::CoreError> { + Ok(()) + } + } + let writer = SqliteWriter::start( + db_path, + Arc::new(NoopBus) as Arc, + )?; + let reads = ReadPool::open(db_path)?; + let file_repo = Arc::new(SqliteFileRepository::new(writer.sender(), reads)); + // WHY writer handle dropped here: the sender inside `file_repo` keeps + // the writer thread alive; the extra handle is not needed for join. + drop(writer); + Ok(crate::cmd::watch::make_db_event_handler( + file_repo, device_id, + )) +} + +// --------------------------------------------------------------------------- +// Dispatchers +// --------------------------------------------------------------------------- + /// Run the `scan` subcommand. // -// WHY `#[allow(clippy::future_not_send)]`: propagates from -// `scan::run` (`on_persist` captures a non-Sync closure). This task -// is awaited directly from `#[tokio::main]` — never sent between -// threads — so the non-Send future is acceptable here. // WHY allow(fn_params_excessive_bools): each bool corresponds to a -// distinct `--flag` on `perima scan`. Collapsing them into an enum -// would either merge orthogonal axes or lose the 1:1 CLI mapping. -#[allow(clippy::future_not_send)] +// distinct `--flag` on `perima scan`. #[allow(clippy::too_many_arguments)] #[allow(clippy::fn_params_excessive_bools)] async fn dispatch_scan( @@ -229,125 +352,82 @@ async fn dispatch_scan( no_wait_metadata, no_thumbnails, }; - let scanner = WalkdirScanner::new(); - let hasher = Blake3Service::new(); - - if dry_run { - // WHY turbofish: both repos are None so the type parameters FR and VR - // are never instantiated, but Rust needs concrete types for - // monomorphisation. SqliteFileRepository / SqliteVolumeRepository are - // the production impls; using them here is a zero-cost hint with no - // allocation because the None branches never call them. - map_scan_result( - cmd::scan::run::<_, _, SqliteFileRepository, SqliteVolumeRepository>( - &scanner, - &hasher, - None, - None, - None, - None, - None, - config.device_id, - cancel, - &args, - ) - .await, - ) + + let db_path = config.data_dir.join("perima.db"); + + // WHY dry-run takes the same container path: the UseCase's + // `FullScan { dry_run: true }` branch skips every DB write and + // volume detection internally — no split path needed. Building the + // container still requires migrations to have run, which is + // harmless for a fresh dry-run against an empty data dir. + let container = match build_container(&db_path, vec![]) { + Ok(c) => c, + Err(e) => { + eprintln!("perima: {e}"); + return ExitCode::from(1); + } + }; + + // WHY sentinel migration closure stays in the shell: the + // `FileRepository` trait doesn't expose `migrate_sentinel_row`; it's + // an impl-specific method on `SqliteFileRepository`. The UseCase + // accepts an opaque `OnPersist = Arc` hook, + // and the CLI constructs one here with its own short-lived + // `SqliteFileRepository` backed by a dedicated writer+pool pair. + // + // WHY a separate writer for the sentinel (not sharing container's + // writer): `migrate_sentinel_row` is an inherent method on the + // concrete `SqliteFileRepository`, not on the `FileRepository` trait; + // calling it requires an `Arc`, not + // `Arc`. Constructing a fresh writer+pool pair + // here is cheap (WAL mode; migrations already ran) and is the only + // path that avoids either widening the port trait or leaking concrete + // types through the container. + let on_persist: cmd::scan::OnPersistFactory = if dry_run { + None } else { - let db_path = config.data_dir.join("perima.db"); - // WHY two separate open_and_migrate calls: SqliteFileRepository and - // SqliteVolumeRepository each take owned Connections wrapped in - // Mutex. Rather than introduce Arc> - // complexity, we open the DB twice. Under WAL mode SQLite allows - // multiple concurrent readers; the second open is instant because - // migrations already ran on the first connection. - let file_conn = match open_and_migrate(&db_path) { - Ok(c) => c, - Err(e) => { - eprintln!("perima: database: {e}"); - return ExitCode::from(1); - } - }; - let vol_conn = match open_and_migrate(&db_path) { - Ok(c) => c, - Err(e) => { - eprintln!("perima: database (volume repo): {e}"); - return ExitCode::from(1); + struct NoopBus; + impl perima_core::events::EventBus for NoopBus { + fn emit(&self, _: &perima_core::AppEvent) -> Result<(), perima_core::CoreError> { + Ok(()) } - }; - let mut file_repo = SqliteFileRepository::new(file_conn); - let mut vol_repo = SqliteVolumeRepository::new(vol_conn); - - // WHY closure for sentinel migration: migrate_sentinel_row is an - // impl-specific method on SqliteFileRepository (not on the - // FileRepository trait). Passing it as a closure lets scan.rs stay - // generic over FR while still invoking the concrete migration in the - // production path. - // - // WHY second DB connection for sentinel migration: the closure and - // the FileRepository mutable borrow cannot alias in safe Rust. We - // open a third lightweight connection for the sentinel UPDATE queries - // only; under WAL mode this is a cheap SELECT + UPDATE path with no - // contention against the scan writer. - let sentinel_conn = match open_and_migrate(&db_path) { - Ok(c) => c, + } + let sentinel_writer = match SqliteWriter::start( + &db_path, + Arc::new(NoopBus) as Arc, + ) { + Ok(w) => w, Err(e) => { eprintln!("perima: database (sentinel migration): {e}"); return ExitCode::from(1); } }; - let sentinel_repo = SqliteFileRepository::new(sentinel_conn); - let device = config.device_id; - let on_persist = |path: &perima_core::MediaPath, - volume: perima_core::VolumeId, - dev: perima_core::DeviceId| { - if let Err(e) = sentinel_repo.migrate_sentinel_row(path, volume, dev) { - tracing::warn!(error = %e, "sentinel migration failed (non-fatal)"); - } - }; - - // WHY separate metadata connection: SqliteMetadataRepository - // owns a Mutex. Under WAL mode another open is - // instant; sharing across repos would require Arc> - // layering none of the existing constructors accept. The same - // rationale applies to file_repo/vol_repo above. - let metadata_conn = match open_and_migrate(&db_path) { - Ok(c) => c, + let sentinel_reads = match ReadPool::open(&db_path) { + Ok(p) => p, Err(e) => { - eprintln!("perima: database (metadata repo): {e}"); + eprintln!("perima: database (sentinel pool): {e}"); return ExitCode::from(1); } }; - let metadata_repo: Arc = - Arc::new(SqliteMetadataRepository::new(metadata_conn)); - - // WHY build the thumbnailer here: `config.data_dir` is the - // root the rest of `scan::run` uses to resolve `perima.db`, so - // thumbnails co-locating under `/thumbnails/...` is - // the simplest layout. `--no-thumbnails` short-circuits to a - // no-op generator that returns `Ok(None)` from `generate`. - let thumbnailer: Arc = Arc::new(if no_thumbnails { - ThumbnailGenerator::disabled() - } else { - ThumbnailGenerator::new(config.data_dir.clone()) - }); - - map_scan_result( - cmd::scan::run( - &scanner, - &hasher, - Some(&mut file_repo), - Some(&mut vol_repo), - Some(metadata_repo), - Some(thumbnailer), - Some(&on_persist), - device, - cancel, - &args, - ) - .await, - ) - } + let sentinel_repo = Arc::new(SqliteFileRepository::new( + sentinel_writer.sender(), + sentinel_reads, + )); + // WHY drop the handle: the sender inside sentinel_repo keeps the + // thread alive; the handle is not needed for join. + drop(sentinel_writer); + Some(Arc::new( + move |path: &perima_core::MediaPath, + volume: perima_core::VolumeId, + dev: perima_core::DeviceId| { + if let Err(e) = sentinel_repo.migrate_sentinel_row(path, volume, dev) { + tracing::warn!(error = %e, "sentinel migration failed (non-fatal)"); + } + }, + ) as perima_app::OnPersist) + }; + + map_scan_result(cmd::scan::run(&container, config.device_id, cancel, on_persist, &args).await) } /// Convert a scan result to a process `ExitCode`. @@ -369,7 +449,7 @@ fn map_scan_result( } /// Run the `ls` subcommand. -fn dispatch_ls( +async fn dispatch_ls( volume: Option, limit: usize, json: bool, @@ -392,30 +472,13 @@ fn dispatch_ls( } }; let db_path = config.data_dir.join("perima.db"); - let file_conn = match open_and_migrate(&db_path) { + let container = match build_container(&db_path, vec![]) { Ok(c) => c, Err(e) => { eprintln!("perima: database: {e}"); return ExitCode::from(1); } }; - let meta_conn = match open_and_migrate(&db_path) { - Ok(c) => c, - Err(e) => { - eprintln!("perima: database (metadata): {e}"); - return ExitCode::from(1); - } - }; - let tag_conn = match open_and_migrate(&db_path) { - Ok(c) => c, - Err(e) => { - eprintln!("perima: database (tag repo): {e}"); - return ExitCode::from(1); - } - }; - let repo = SqliteFileRepository::new(file_conn); - let metadata_repo = SqliteMetadataRepository::new(meta_conn); - let tag_repo = SqliteTagRepository::new(tag_conn); let ls_args = cmd::ls::LsArgs { volume: volume_id, limit, @@ -423,8 +486,12 @@ fn dispatch_ls( with_metadata, tag, }; - match cmd::ls::run(&repo, &metadata_repo, &tag_repo, &ls_args) { + match cmd::ls::run(&container, &config.data_dir, config.device_id, &ls_args).await { Ok(()) => ExitCode::from(0), + Err(perima_core::CoreError::NotFound(msg)) => { + eprintln!("perima: {msg}"); + ExitCode::from(1) + } Err(e) => { eprintln!("perima: {e}"); ExitCode::from(1) @@ -433,25 +500,16 @@ fn dispatch_ls( } /// Run the `tag` subcommand. -fn dispatch_tag(args: &cmd::tag::TagArgs, config: &Config) -> ExitCode { +async fn dispatch_tag(args: &cmd::tag::TagArgs, config: &Config) -> ExitCode { let db_path = config.data_dir.join("perima.db"); - let tag_conn = match open_and_migrate(&db_path) { - Ok(c) => c, - Err(e) => { - eprintln!("perima: database (tag repo): {e}"); - return ExitCode::from(1); - } - }; - let file_conn = match open_and_migrate(&db_path) { + let container = match build_container(&db_path, vec![]) { Ok(c) => c, Err(e) => { - eprintln!("perima: database (file repo): {e}"); + eprintln!("perima: database: {e}"); return ExitCode::from(1); } }; - let tag_repo = SqliteTagRepository::new(tag_conn); - let file_repo = SqliteFileRepository::new(file_conn); - match cmd::tag::run(&tag_repo, &file_repo, config.device_id, args) { + match cmd::tag::run(&container, &config.data_dir, config.device_id, args).await { Ok(()) => ExitCode::from(0), Err(e) => { eprintln!("perima: {e}"); @@ -462,7 +520,37 @@ fn dispatch_tag(args: &cmd::tag::TagArgs, config: &Config) -> ExitCode { /// Run the `watch` subcommand. async fn dispatch_watch(root: PathBuf, config: &Config, cancel: &Cancellation) -> ExitCode { - match cmd::watch::run(&config.data_dir, config.device_id, &root, cancel).await { + let db_path = config.data_dir.join("perima.db"); + + // WHY build the DbEventHandler here and inject via extra_handlers: + // watch needs a DB handler so filesystem events mutate location rows. + // Constructing it here (before AppContainer::new) keeps Bus + // construction in exactly one place — Batch E spec §2.1 single- + // construction-site invariant. + let db_handler = match build_watch_db_handler(&db_path, config.device_id) { + Ok(h) => h, + Err(e) => { + eprintln!("perima: database (watch handler): {e}"); + return ExitCode::from(1); + } + }; + + let container = match build_container(&db_path, vec![db_handler]) { + Ok(c) => c, + Err(e) => { + eprintln!("perima: database: {e}"); + return ExitCode::from(1); + } + }; + match cmd::watch::run( + &container, + &config.data_dir, + config.device_id, + &root, + cancel, + ) + .await + { Ok(()) => ExitCode::from(0), Err(perima_core::CoreError::InvalidPath(msg)) => { eprintln!("perima: {msg}"); @@ -475,10 +563,21 @@ async fn dispatch_watch(root: PathBuf, config: &Config, cancel: &Cancellation) - } } -/// Run the `metadata` subcommand. +/// Run the `metadata` subcommand (single-file re-extract — NOT migrated to +/// a `UseCase` in Batch B; the single-file extraction path is post-v1 work). async fn dispatch_metadata(path: PathBuf, json: bool, config: &Config) -> ExitCode { let args = cmd::metadata::MetadataArgs { path, json }; - match cmd::metadata::run(&config.data_dir, config.device_id, &args).await { + // WHY build_container here: `cmd::metadata::run` now consumes + // `AppContainer.volumes` for `find_or_create` (Batch C Task 2). + 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::metadata::run(&container, &config.data_dir, config.device_id, &args).await { Ok(()) => ExitCode::from(0), Err(perima_core::CoreError::InvalidPath(msg)) => { eprintln!("perima: {msg}"); @@ -492,17 +591,16 @@ async fn dispatch_metadata(path: PathBuf, json: bool, config: &Config) -> ExitCo } /// Run the `search` subcommand. -fn dispatch_search(args: &cmd::search::SearchArgs, config: &Config) -> ExitCode { +async fn dispatch_search(args: &cmd::search::SearchArgs, config: &Config) -> ExitCode { let db_path = config.data_dir.join("perima.db"); - let conn = match open_and_migrate(&db_path) { + let container = match build_container(&db_path, vec![]) { Ok(c) => c, Err(e) => { - eprintln!("perima: database (search repo): {e}"); + eprintln!("perima: database (search): {e}"); return ExitCode::from(1); } }; - let repo = SqliteSearchRepository::new(conn); - match cmd::search::run(&repo, args) { + match cmd::search::run(&container, args).await { Ok(()) => ExitCode::from(0), Err(e) => { eprintln!("perima: {e}"); @@ -512,17 +610,16 @@ fn dispatch_search(args: &cmd::search::SearchArgs, config: &Config) -> ExitCode } /// Run the `volumes` subcommand. -fn dispatch_volumes(config: &Config) -> ExitCode { +async fn dispatch_volumes(config: &Config) -> ExitCode { let db_path = config.data_dir.join("perima.db"); - let conn = match open_and_migrate(&db_path) { + let container = match build_container(&db_path, vec![]) { Ok(c) => c, Err(e) => { eprintln!("perima: database: {e}"); return ExitCode::from(1); } }; - let repo = SqliteVolumeRepository::new(conn); - match cmd::volumes::run(&repo, config.device_id) { + match cmd::volumes::run(&container, config.device_id).await { Ok(()) => ExitCode::from(0), Err(e) => { eprintln!("perima: {e}"); diff --git a/crates/cli/src/signals.rs b/crates/cli/src/signals.rs index bbf0e5e..babd928 100644 --- a/crates/cli/src/signals.rs +++ b/crates/cli/src/signals.rs @@ -15,18 +15,13 @@ pub(crate) struct Cancellation { } impl Cancellation { - /// Has a cancellation signal been received? - #[must_use] - pub(crate) fn cancelled(&self) -> bool { - self.token.is_cancelled() - } - /// Clone the underlying [`CancellationToken`] for use in a rayon closure /// or an async task. /// /// WHY cloned not referenced: `CancellationToken` is an `Arc`-wrapped /// inner handle, so `clone()` is O(1) and the clone shares state with the - /// original. Callers that only need a boolean should prefer `cancelled()`. + /// original. Callers needing a boolean check can call `.is_cancelled()` + /// on the cloned token directly. #[must_use] pub(crate) fn token(&self) -> CancellationToken { self.token.clone() diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 4384a56..3154a8d 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -12,11 +12,20 @@ thiserror.workspace = true serde.workspace = true uuid.workspace = true unicode-normalization.workspace = true +# WHY features = ["derive", "uuid"]: specta::Type proc-macro lives behind +# the "derive" feature. The "uuid" feature adds specta::Type impl for +# uuid::Uuid so VolumeId/DeviceId(uuid::Uuid) can use #[specta(transparent)]. +specta = { workspace = true, optional = true, features = ["derive", "uuid"] } + +[features] +default = [] +specta = ["dep:specta"] [dev-dependencies] -proptest.workspace = true -blake3.workspace = true -tempfile.workspace = true +proptest.workspace = true +blake3.workspace = true +tempfile.workspace = true +serde_json.workspace = true [lints] workspace = true diff --git a/crates/core/src/errors.rs b/crates/core/src/errors.rs index 6bb155b..fb61df2 100644 --- a/crates/core/src/errors.rs +++ b/crates/core/src/errors.rs @@ -5,10 +5,22 @@ //! so that `core` depends on no adapter (preserves hexagonal //! direction). -use thiserror::Error; - -/// Error returned by every `core` trait method. -#[derive(Debug, Error)] +/// Stable, serializable error type that crosses the Tauri IPC boundary. +/// +/// WHY discriminated union (`#[serde(tag = "kind", content = "data")]`): +/// the TypeScript binding compiles to `{ kind: "NotFound"; data: string } | ...` +/// which the frontend pattern-matches via `switch (err.kind)`. Replaces the +/// pre-Batch-D `Result` + regex-on-prose discrimination +/// (audit §3.11 + §4.3). +/// +/// WHY no `miette::Diagnostic` derive: `miette` is a binary-UX-only +/// concern (L7 landed it in `crates/cli` + `crates/desktop` only). Adding +/// it to `crates/core` violates the "no framework deps in core" rule. +/// Binaries can wrap `CoreError` in `miette::Report` at their own edge. +/// (Umbrella spec §1.4 #2.) +#[derive(Debug, Clone, thiserror::Error, serde::Serialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(tag = "kind", content = "data")] pub enum CoreError { /// Queried item was absent. #[error("not found: {0}")] @@ -30,9 +42,19 @@ pub enum CoreError { #[error("invalid tag: {0}")] InvalidTag(String), - /// Underlying I/O failure. - #[error("io: {0}")] - Io(#[from] std::io::Error), + /// Underlying I/O failure, lowered from `std::io::Error`. + /// + /// WHY struct variant: `std::io::Error` is `!Serialize` and `!Clone`. + /// Capture `kind()` (e.g. `"NotFound"`, `"PermissionDenied"`) + display + /// message at the conversion site so the frontend can branch on + /// recoverable vs not. + #[error("io [{kind}]: {message}")] + Io { + /// The `std::io::ErrorKind` debug name (e.g. `"PermissionDenied"`). + kind: String, + /// The original display message from the `std::io::Error`. + message: String, + }, /// Feature is declared but not yet implemented at this phase. /// Dedicated variant so `main.rs` can map to a stable exit code @@ -44,3 +66,17 @@ pub enum CoreError { #[error("internal: {0}")] Internal(String), } + +// WHY explicit From, not #[from]: Io is now a struct variant capturing +// kind+message. The pre-Batch-D `#[from] std::io::Error` pattern requires +// the variant to wrap io::Error directly — which conflicts with both +// the Serialize derive (io::Error is !Serialize) and the Clone derive +// (io::Error is !Clone). DO NOT switch back to #[from]. +impl From for CoreError { + fn from(e: std::io::Error) -> Self { + Self::Io { + kind: format!("{:?}", e.kind()), + message: e.to_string(), + } + } +} diff --git a/crates/core/src/events.rs b/crates/core/src/events.rs index 3cf13c5..72701ac 100644 --- a/crates/core/src/events.rs +++ b/crates/core/src/events.rs @@ -5,7 +5,15 @@ use serde::Serialize; use crate::{CoreError, MediaPath, VolumeId}; /// A filesystem event detected by the watcher. +/// +/// WHY `tag = "type"` (inline, no content key): matches the pre-Batch-D +/// `FileEventPayload` mirror in `crates/desktop/src/events.rs`, keeping the +/// frontend's `'file-event'` channel listener byte-compatible. `CoreError` uses +/// `tag = "kind", content = "data"` — a different shape intentionally, because +/// `CoreError` is a Result error type while `FileEvent` is a v1-frozen channel payload. #[derive(Clone, Debug, Serialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(tag = "type")] pub enum FileEvent { /// A new file appeared at this path. Created { @@ -39,16 +47,74 @@ pub enum FileEvent { }, } -/// Consumer of filesystem events. +/// Application-level event broadcast through the bus. +/// +/// Wraps the existing [`FileEvent`] for filesystem-watcher events and +/// adds two domain events emitted by the writer / use-case layer. +/// +/// Wire shape: `#[serde(tag = "kind", content = "data")]` produces a +/// TypeScript discriminated union the frontend pattern-matches on. +/// Inner `FileEvent` keeps its own `#[serde(tag = "type")]` inline +/// shape (Batch D D-4 invariant) — wraps cleanly inside `AppEvent::File`. +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(tag = "kind", content = "data")] +pub enum AppEvent { + /// Filesystem-watcher event (Created/Modified/Deleted/Renamed). + File(FileEvent), + + /// Emitted by `ScanUseCase::execute` after a successful scan. + /// Frontend triggers an immediate refetch (no debounce). + ScanCompleted { + /// Volume the scan ran against. + volume: VolumeId, + /// Total files seen by the walker (incl. existing). + files_seen: u64, + /// New files inserted since last scan. + files_new: u64, + /// Wall-clock duration of the scan. + duration_ms: u64, + }, + + /// Emitted by the writer actor after a successful `WriteCmd` that + /// changes a query-relevant index. + IndexInvalidated { + /// Which index category was invalidated. + reason: InvalidationReason, + }, +} + +/// Categorical reason an index was invalidated. +/// +/// Kept coarse in v1 — Batch H may split into per-row variants once +/// `TanStack` Query lands and profiling shows surgical invalidation pays. +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +pub enum InvalidationReason { + /// Tag attach/detach. + TagsChanged, + /// File location upsert / status change. + FilesChanged, + /// Media metadata extraction or attach. + MetadataChanged, + /// FTS5 rebuild. + SearchIndexRebuilt, +} + +/// Consumer of application events. /// /// Multiple implementations can be composed via a fan-out adapter /// (e.g., `CompositeEventBus`). The composite logs errors from /// individual handlers but does not abort — remaining handlers /// still fire. pub trait EventBus: Send + Sync { - /// Process an event. + /// Publish an event onto the bus. Implementations are expected to + /// be cheap (clone + push to channel); slow work happens in the + /// async `EventHandler` tasks spawned by `AppContainer::new`. /// /// # Errors - /// Returns `CoreError` if the handler fails (e.g., DB write). - fn emit(&self, event: &FileEvent) -> Result<(), CoreError>; + /// Returns `CoreError` if the publish fails. The production `Bus` + /// (in `crates/app::bus`) returns `Ok(())` on capacity-Full (logs + /// a warning instead) and on Closed (shutdown path). + fn emit(&self, event: &AppEvent) -> Result<(), CoreError>; } diff --git a/crates/core/src/hlc.rs b/crates/core/src/hlc.rs new file mode 100644 index 0000000..7410baf --- /dev/null +++ b/crates/core/src/hlc.rs @@ -0,0 +1,298 @@ +//! Hybrid Logical Clock (HLC) for CRDT ordering. +//! +//! Per architecture audit §4.8, perima's schema reserves an `hlc` +//! column on every CRDT-eligible mutable row. This module provides +//! the generation + packing primitive; populating the column is +//! writer-side work (Batch B+). +//! +//! # Layout +//! +//! A packed HLC is a non-negative `i64`: +//! +//! - bits 0..=15 — same-ms monotonic counter (full u16, max 65535) +//! - bits 16..=62 — millisecond timestamp (47 bits = ~4460 years from the Unix epoch) +//! - bit 63 — always 0 (i64 sign bit; enforced by [`HLC_MAX_MS`]) +//! +//! This layout puts `ms` in the HIGH bits and `counter` in the LOW +//! bits, so `Ord` over the packed `i64` matches `Ord` over +//! `(ms, counter)` — the same order that `Hlc` derives via +//! `#[derive(Ord)]` with `ms` as the first field. This Ord match is +//! load-bearing for SQL `ORDER BY hlc`. +//! +//! # Monotonicity +//! +//! `Hlc::now()` is monotonic even under wall-clock step-back (NTP +//! adjustment, manual clock change). If `SystemTime::now()` returns +//! a ms `<= last`, the helper reuses `last` and bumps the counter. +//! If the counter saturates within one ms, the returned HLC advances +//! `ms` by 1 to preserve the total order. + +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Hybrid Logical Clock value. +/// +/// Monotonically non-decreasing per process via [`Hlc::now`]. +/// `#[derive(Ord)]` yields the intended `(ms, counter)` lexicographic +/// order — the same order the packed i64 form exposes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Hlc { + /// Milliseconds since the Unix epoch (bits 16-62 of packed form; + /// capped at [`HLC_MAX_MS`]). + pub ms: u64, + /// Same-ms tiebreak counter (bits 0-15 of packed form; full u16 + /// range). + pub counter: u16, +} + +/// Maximum `ms` representable in the packed form (2^47 - 1 ≈ 4460 +/// years from Unix epoch). Capped at 47 bits so that the packed `i64` +/// sign bit (bit 63) stays 0. +pub const HLC_MAX_MS: u64 = (1u64 << 47) - 1; + +/// Maximum `counter` representable in the packed form ([`u16::MAX`]). +/// Counter lives in the low 16 bits; no sign-bit concern. +pub const HLC_MAX_COUNTER: u16 = u16::MAX; + +/// Shared mutable state for [`Hlc::now`]. Per-process; across +/// processes the counter resets but `ms` typically advances between +/// restarts. For the single-writer model this is acceptable; v2+ +/// multi-device sync can persist last-hlc in `device_config`. +static HLC_STATE: Mutex = Mutex::new(Hlc { ms: 0, counter: 0 }); + +impl Hlc { + /// Generate a new HLC, monotonically non-decreasing relative to + /// every prior call in the same process. + /// + /// # Saturation (v1 acceptable limit) + /// + /// If the internal state reaches `ms == HLC_MAX_MS` AND + /// `counter == HLC_MAX_COUNTER`, further calls will reset the + /// counter to 0 at the same `ms`, breaking strict `Ord` + /// monotonicity. Unreachable in practice (~year 6429 from epoch) + /// but documented here. Multi-device sync in v2 will persist + /// `last_hlc` in `device_config` and detect this explicitly. + /// + /// # Panics + /// + /// Panics if the internal HLC mutex is poisoned (only possible if a + /// prior call panicked while holding the lock, which is unreachable + /// in normal operation). + #[must_use] + #[allow(clippy::cast_possible_truncation)] // WHY: d.as_millis() is u128 but clamped below HLC_MAX_MS (2^47-1 < 2^64). + pub fn now() -> Self { + let wall_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64) + .min(HLC_MAX_MS); + + let mut state = HLC_STATE.lock().expect("HLC mutex poisoned"); + if wall_ms > state.ms { + *state = Self { + ms: wall_ms, + counter: 0, + }; + } else if state.counter == HLC_MAX_COUNTER { + // Counter saturated within one ms — advance ms to + // preserve total order. At HLC_MAX_MS this saturates + // (documented above). + *state = Self { + ms: state.ms.saturating_add(1).min(HLC_MAX_MS), + counter: 0, + }; + } else { + state.counter += 1; + } + *state + } + + /// Pack into a non-negative `i64` suitable for `SQLite` `INTEGER` + /// storage. Bit 63 stays 0 because [`HLC_MAX_MS`] caps `ms` at + /// 47 bits; `ms` occupies bits 16-62 and `counter` bits 0-15. + /// `Ord` over the packed `i64` matches `Ord` over `(ms, counter)`. + #[must_use] + #[allow(clippy::cast_possible_wrap)] // WHY: ms capped at HLC_MAX_MS keeps bit 63 = 0 after the <<16; always non-negative i64. + pub const fn pack(&self) -> i64 { + let ms_bits = (self.ms & HLC_MAX_MS) << 16; + let counter_bits = self.counter as u64; + (ms_bits | counter_bits) as i64 + } + + /// Inverse of [`Hlc::pack`]. Accepts any `i64` produced by `pack`; + /// behaviour on values with bit-63 set is undefined (packed HLCs + /// are always non-negative). + #[must_use] + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] // WHY: packed values from pack() are always non-negative i64 with known bit layout. + pub const fn unpack(packed: i64) -> Self { + let bits = packed as u64; + let counter = (bits & 0xFFFF) as u16; + let ms = (bits >> 16) & HLC_MAX_MS; + Self { ms, counter } + } +} + +#[cfg(test)] +#[allow(clippy::cast_possible_truncation)] // WHY: tests clamp ms below HLC_MAX_MS before casting u128 → u64. +mod tests { + use super::*; + + // WHY: the 4 state-mutating tests (monotonicity, clock-backward, + // counter-tiebreak, counter-overflow) all read or write the + // process-global `HLC_STATE`. Default test parallelism causes + // cross-test state pollution. Serialize via TEST_LOCK. The pure + // pack/unpack round-trip test doesn't need the lock. + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn pack_unpack_round_trip_across_ranges() { + for (ms, counter) in [ + (0u64, 0u16), + (1_700_000_000_000u64, 0u16), + (1_700_000_000_000u64, 42u16), + (HLC_MAX_MS, HLC_MAX_COUNTER), + (HLC_MAX_MS - 1, 0u16), + (0u64, HLC_MAX_COUNTER), + ] { + let h = Hlc { ms, counter }; + assert_eq!( + Hlc::unpack(h.pack()), + h, + "round-trip failed for ({ms}, {counter})" + ); + assert!( + h.pack() >= 0, + "pack produced negative i64 for ({ms}, {counter})" + ); + } + } + + #[test] + fn packed_ord_matches_derived_ord() { + // The packed i64 form MUST preserve `(ms, counter)` ordering + // so SQL `ORDER BY hlc` returns rows in HLC order. + let cases = [ + ( + Hlc { + ms: 100, + counter: 0, + }, + Hlc { ms: 50, counter: 1 }, + ), + (Hlc { ms: 0, counter: 1 }, Hlc { ms: 0, counter: 0 }), + ( + Hlc { ms: 1, counter: 0 }, + Hlc { + ms: 0, + counter: HLC_MAX_COUNTER, + }, + ), + ( + Hlc { + ms: HLC_MAX_MS, + counter: 0, + }, + Hlc { + ms: HLC_MAX_MS - 1, + counter: HLC_MAX_COUNTER, + }, + ), + ( + Hlc { + ms: 42, + counter: 42, + }, + Hlc { + ms: 42, + counter: 42, + }, + ), + ]; + for (a, b) in cases { + assert_eq!( + a.cmp(&b), + a.pack().cmp(&b.pack()), + "packed Ord mismatches derived Ord for {a:?} vs {b:?} (packed {} vs {})", + a.pack(), + b.pack() + ); + } + } + + #[test] + fn now_is_monotonically_non_decreasing_under_rapid_calls() { + let _guard = TEST_LOCK.lock().expect("test lock poisoned"); + let mut last = Hlc::now(); + for _ in 0..10_000 { + let curr = Hlc::now(); + assert!(curr >= last, "HLC regressed: {curr:?} < {last:?}"); + last = curr; + } + } + + #[test] + fn clock_backward_safety_via_state_reuse() { + let _guard = TEST_LOCK.lock().expect("test lock poisoned"); + // Seed HLC_STATE to a future ms, then generate. The new HLC + // must not regress to a prior ms even if wall clock is behind. + { + let mut state = HLC_STATE.lock().expect("mutex"); + *state = Hlc { + ms: state.ms.max(1_900_000_000_000), + counter: 0, + }; + } + let prior = *HLC_STATE.lock().expect("mutex"); + let h = Hlc::now(); + assert!(h >= prior, "HLC regressed below forced-future state"); + } + + #[test] + fn counter_tiebreak_within_same_ms() { + let _guard = TEST_LOCK.lock().expect("test lock poisoned"); + // Pin state to a fixed ms and call now() multiple times; the + // counter should advance within the same ms unless the wall + // clock happens to tick past it mid-test. + { + let mut state = HLC_STATE.lock().expect("mutex"); + *state = Hlc { + ms: state.ms.max( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64), + ), + counter: 0, + }; + } + let a = Hlc::now(); + let b = Hlc::now(); + let c = Hlc::now(); + assert!( + a <= b && b <= c, + "non-monotonic within-ms: {a:?} {b:?} {c:?}" + ); + } + + #[test] + fn counter_overflow_within_same_ms_advances_ms() { + let _guard = TEST_LOCK.lock().expect("test lock poisoned"); + // Force counter to saturation; next call must advance ms. + let start_ms; + { + let mut state = HLC_STATE.lock().expect("mutex"); + start_ms = state.ms.max( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64), + ); + *state = Hlc { + ms: start_ms, + counter: HLC_MAX_COUNTER, + }; + } + let h = Hlc::now(); + assert!( + h.ms > start_ms || (h.ms == start_ms && h.counter == 0), + "overflow did not advance ms or reset counter: start_ms={start_ms} got={h:?}" + ); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 6e4317c..b0d28b2 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -6,6 +6,7 @@ pub mod errors; pub mod events; +pub mod hlc; pub mod ids; pub mod metadata; pub mod search; @@ -13,7 +14,8 @@ pub mod tag; pub mod types; pub use errors::CoreError; -pub use events::{EventBus, FileEvent}; +pub use events::{AppEvent, EventBus, FileEvent, InvalidationReason}; +pub use hlc::{HLC_MAX_COUNTER, HLC_MAX_MS, Hlc}; pub use metadata::{MediaMetadata, MetadataExtractor}; pub use search::SearchHit; pub use tag::{MAX_TAG_LEN, Tag, normalize as normalize_tag}; diff --git a/crates/core/src/metadata.rs b/crates/core/src/metadata.rs index 28f7644..84f19ac 100644 --- a/crates/core/src/metadata.rs +++ b/crates/core/src/metadata.rs @@ -20,6 +20,7 @@ use crate::{BlakeHash, CoreError}; /// consistency with the existing `first_seen` / `last_seen` String /// columns, and it keeps `chrono` out of `perima-core`. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] pub struct MediaMetadata { /// Content hash of the file this metadata describes. Matches the /// corresponding row in the `files` table. diff --git a/crates/core/src/search.rs b/crates/core/src/search.rs index a28da42..01bb649 100644 --- a/crates/core/src/search.rs +++ b/crates/core/src/search.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; /// The `rank` field follows `SQLite`'s BM25 convention: lower (more negative) /// values indicate a better match. Callers should sort ascending. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] pub struct SearchHit { /// `BLAKE3` hex hash of the file content. pub blake3_hash: String, diff --git a/crates/core/src/tag.rs b/crates/core/src/tag.rs index d712f3e..7d64bdb 100644 --- a/crates/core/src/tag.rs +++ b/crates/core/src/tag.rs @@ -21,6 +21,7 @@ pub const MAX_TAG_LEN: usize = 64; /// future FFI/HTTP adapters don't need it. Keeps the domain type /// minimal and stable. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] pub struct Tag { /// `UUIDv7` primary key. pub id: Uuid, diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index fa00072..8f95b7f 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -23,6 +23,11 @@ use crate::errors::CoreError; /// impl serializes as a 64-char hex string (not a raw byte array) /// so JSON consumers see `"a1b2c3..."` instead of `[161, 178, ...]`. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +// WHY specta(type = String): the custom Serialize impl emits a hex string, +// not a byte array, so specta must be told the TypeScript type is `string` +// rather than inferring `number[]` from the inner `[u8; 32]`. +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[cfg_attr(feature = "specta", specta(type = String))] pub struct BlakeHash([u8; 32]); impl Serialize for BlakeHash { @@ -110,6 +115,7 @@ const fn parse_nibble(b: u8) -> Option { /// File size in bytes. Newtype to prevent arithmetic with other u64s. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] pub struct FileSize(pub u64); /// Path relative to a volume root. Forward-slash, no leading slash. @@ -132,7 +138,11 @@ pub struct FileSize(pub u64); /// `MediaPaths` from user-typed strings (rare in production; this /// type is typically machine-derived from canonicalized walk output) /// accept the byte-exact-match contract. +// WHY specta(transparent): inner field is a `String`, so the TypeScript +// type should be `string` rather than `{ "0": string }`. #[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[cfg_attr(feature = "specta", specta(transparent))] pub struct MediaPath(String); impl MediaPath { @@ -157,7 +167,11 @@ impl MediaPath { use std::path::PathBuf; /// `UUIDv7` volume identifier. +// WHY specta(transparent): `uuid::Uuid` maps to `string` in specta's +// built-in type map, so the TS binding should be `string`, not `{ "0": string }`. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[cfg_attr(feature = "specta", specta(transparent))] pub struct VolumeId(pub uuid::Uuid); impl VolumeId { @@ -176,6 +190,8 @@ impl Default for VolumeId { /// `UUIDv7` device identifier. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[cfg_attr(feature = "specta", specta(transparent))] pub struct DeviceId(pub uuid::Uuid); impl DeviceId { @@ -219,6 +235,7 @@ pub struct HashedFile { /// Status of a file location row. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] pub enum LocationStatus { /// Visible on the expected volume at the expected path. Active, @@ -244,6 +261,7 @@ pub enum UpsertOutcome { /// Row returned by `FileRepository::list_file_locations`. #[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] pub struct FileLocationRecord { /// Content hash of the underlying file. pub hash: BlakeHash, @@ -275,7 +293,8 @@ pub struct VolumeIdentifiers { } /// Row returned by `VolumeRepository::list`. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] pub struct VolumeRecord { /// Volume id. pub id: VolumeId, diff --git a/crates/core/tests/serialize_shape.rs b/crates/core/tests/serialize_shape.rs new file mode 100644 index 0000000..cc3647f --- /dev/null +++ b/crates/core/tests/serialize_shape.rs @@ -0,0 +1,314 @@ +//! Round-trip tests for the JSON shapes that cross the IPC boundary. +//! +//! WHY: `bindings.ts` is generated from `#[derive(specta::Type)]` on these +//! types but the runtime serialization comes from `#[derive(Serialize)]`. +//! These tests pin the wire shape so a future field rename or struct +//! variant tweak fails here loudly instead of silently breaking the +//! frontend's `parseCoreError` matcher. + +use perima_core::{ + AppEvent, BlakeHash, CoreError, FileEvent, FileLocationRecord, FileSize, InvalidationReason, + LocationStatus, MediaMetadata, MediaPath, SearchHit, Tag, VolumeId, VolumeRecord, +}; + +#[test] +fn core_error_not_found_serializes_with_kind_and_data() { + let err = CoreError::NotFound("file 42".to_owned()); + let json = serde_json::to_string(&err).expect("serialize"); + let v: serde_json::Value = serde_json::from_str(&json).expect("parse"); + assert_eq!(v["kind"], "NotFound"); + assert_eq!(v["data"], "file 42"); +} + +#[test] +fn core_error_io_serializes_as_struct_variant_with_kind_and_message() { + let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"); + let err: CoreError = io_err.into(); + let json = serde_json::to_string(&err).expect("serialize"); + let v: serde_json::Value = serde_json::from_str(&json).expect("parse"); + assert_eq!(v["kind"], "Io"); + assert_eq!(v["data"]["kind"], "PermissionDenied"); + assert!( + v["data"]["message"] + .as_str() + .expect("message field present and string-typed") + .contains("denied") + ); +} + +#[test] +fn core_error_implements_clone() { + // WHY: Lowering Io to a struct variant unblocks Clone, which is + // useful for the bindings-compile test fixture and future + // event-replay scenarios (Batch E). + // WHY: compile-time proof is sufficient — a runtime .clone() would be flagged + // as redundant_clone by clippy since `err` is never used after the call. + fn _assert_clone() {} + let _ = _assert_clone::; +} + +// ── IPC domain-type wire-shape tests (6 types) ───────────────────────────── +// WHY: Specta derives inform the TypeScript types; serde derives drive the +// runtime wire format. Both must agree, so we pin the JSON shape here. +// A field rename or wrapper change causes a loud failure instead of a silent +// frontend type mismatch. + +#[test] +fn blake_hash_serializes_as_lowercase_hex_string() { + // All-zeros hash → 64 '0' characters. + let h = BlakeHash::from_bytes([0u8; 32]); + let v = serde_json::to_value(h).expect("serialize BlakeHash"); + let s = v.as_str().expect("BlakeHash JSON must be a string"); + assert_eq!( + s.len(), + 64, + "BlakeHash JSON string must be exactly 64 chars" + ); + assert!( + s.chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), + "BlakeHash JSON string must be lowercase hex" + ); + assert_eq!( + s, + "0".repeat(64), + "all-zeros hash must produce 64 '0' chars" + ); +} + +#[test] +fn file_size_serializes_as_number() { + let fs = FileSize(42); + let v = serde_json::to_value(fs).expect("serialize FileSize"); + assert_eq!( + v, + serde_json::json!(42), + "FileSize JSON must be a bare number" + ); +} + +#[test] +fn media_path_serializes_as_string() { + let p = MediaPath::new("photos/img.jpg"); + let v = serde_json::to_value(&p).expect("serialize MediaPath"); + assert_eq!( + v, + serde_json::json!("photos/img.jpg"), + "MediaPath JSON must be the normalized path string" + ); +} + +#[test] +fn volume_id_serializes_as_uuid_string() { + let id = VolumeId(uuid::Uuid::nil()); + let v = serde_json::to_value(id).expect("serialize VolumeId"); + assert_eq!( + v, + serde_json::json!("00000000-0000-0000-0000-000000000000"), + "VolumeId JSON must be a hyphenated UUID string" + ); +} + +#[test] +fn file_location_record_serializes_with_string_typed_fields() { + let record = FileLocationRecord { + hash: BlakeHash::from_bytes([0xabu8; 32]), + size: FileSize(1024), + volume_id: VolumeId(uuid::Uuid::nil()), + relative_path: MediaPath::new("docs/spec.md"), + status: LocationStatus::Active, + first_seen: "2026-04-22T00:00:00Z".to_owned(), + }; + let v = serde_json::to_value(&record).expect("serialize FileLocationRecord"); + // Verify object shape: all expected keys present and typed correctly. + assert!(v["hash"].is_string(), "hash must serialize as string"); + assert_eq!(v["hash"].as_str().expect("hash is string").len(), 64); + assert!(v["size"].is_number(), "size must serialize as number"); + assert_eq!(v["size"], serde_json::json!(1024)); + assert!( + v["volume_id"].is_string(), + "volume_id must serialize as string" + ); + assert!( + v["relative_path"].is_string(), + "relative_path must serialize as string" + ); + assert_eq!(v["relative_path"], serde_json::json!("docs/spec.md")); + assert_eq!(v["status"], serde_json::json!("Active")); + assert_eq!(v["first_seen"], serde_json::json!("2026-04-22T00:00:00Z")); +} + +#[test] +fn volume_record_serializes_with_object_shape() { + use std::path::PathBuf; + let record = VolumeRecord { + id: VolumeId(uuid::Uuid::nil()), + label: Some("MyDrive".to_owned()), + capacity_bytes: 1_000_000, + is_removable: true, + mounts_on_this_machine: vec![PathBuf::from("/mnt/vol")], + last_seen: "2026-04-22T00:00:00Z".to_owned(), + }; + let v = serde_json::to_value(&record).expect("serialize VolumeRecord"); + // Verify object shape: all expected keys present. + assert!(v["id"].is_string(), "id must serialize as string"); + assert_eq!( + v["id"], + serde_json::json!("00000000-0000-0000-0000-000000000000") + ); + assert_eq!(v["label"], serde_json::json!("MyDrive")); + assert_eq!(v["capacity_bytes"], serde_json::json!(1_000_000_u64)); + assert_eq!(v["is_removable"], serde_json::json!(true)); + assert!( + v["mounts_on_this_machine"].is_array(), + "mounts_on_this_machine must serialize as array" + ); + assert_eq!(v["last_seen"], serde_json::json!("2026-04-22T00:00:00Z")); +} + +// ── E4: single-type file IPC shapes (4 types) ────────────────────────────── +// WHY: MediaMetadata, Tag, SearchHit, FileEvent each live in their own file +// and cross the IPC boundary. Pinning their wire shapes here catches field +// renames and serde-tag changes before they silently break the frontend. + +#[test] +fn media_metadata_serializes_as_object() { + let meta = MediaMetadata { + hash: BlakeHash::from_bytes([0u8; 32]), + width: Some(1920), + height: Some(1080), + duration_ms: None, + captured_at: Some("2026-04-22T12:00:00Z".to_owned()), + camera_make: None, + camera_model: None, + codec: None, + bitrate_bps: None, + mime_type: Some("image/jpeg".to_owned()), + thumbnail_path: None, + thumbnail_status: None, + }; + let v = serde_json::to_value(&meta).expect("serialize MediaMetadata"); + assert!(v.is_object(), "MediaMetadata JSON must be an object"); + assert!(v["hash"].is_string(), "hash must serialize as string"); + assert_eq!(v["width"], serde_json::json!(1920)); + assert_eq!(v["height"], serde_json::json!(1080)); + assert_eq!(v["duration_ms"], serde_json::Value::Null); + assert_eq!(v["captured_at"], serde_json::json!("2026-04-22T12:00:00Z")); + assert_eq!(v["mime_type"], serde_json::json!("image/jpeg")); +} + +#[test] +fn tag_serializes_with_id_name_first_seen() { + // WHY: Tag.first_seen (not created_at) — confirmed via MCP discovery. + let tag = Tag { + id: uuid::Uuid::nil(), + name: "nature".to_owned(), + first_seen: "2026-04-22T00:00:00Z".to_owned(), + }; + let v = serde_json::to_value(&tag).expect("serialize Tag"); + assert!(v.is_object(), "Tag JSON must be an object"); + assert_eq!( + v["id"], + serde_json::json!("00000000-0000-0000-0000-000000000000") + ); + assert_eq!(v["name"], serde_json::json!("nature")); + assert_eq!(v["first_seen"], serde_json::json!("2026-04-22T00:00:00Z")); +} + +#[test] +fn search_hit_serializes_with_blake3_hash_and_rank() { + let hit = SearchHit { + blake3_hash: "abc123".to_owned(), + volume_id: "00000000-0000-0000-0000-000000000000".to_owned(), + relative_path: "photos/img.jpg".to_owned(), + rank: -1.5_f64, + }; + let v = serde_json::to_value(&hit).expect("serialize SearchHit"); + assert!(v.is_object(), "SearchHit JSON must be an object"); + assert_eq!(v["blake3_hash"], serde_json::json!("abc123")); + assert_eq!( + v["volume_id"], + serde_json::json!("00000000-0000-0000-0000-000000000000") + ); + assert_eq!(v["relative_path"], serde_json::json!("photos/img.jpg")); + assert_eq!(v["rank"], serde_json::json!(-1.5_f64)); +} + +#[test] +fn file_event_created_serializes_with_kind_and_data() { + // WHY: FileEvent uses #[serde(tag = "type")] inline (no content key), + // matching the pre-Batch-D FileEventPayload mirror in desktop. + // This is DIFFERENT from CoreError (tag="kind", content="data"). + let event = FileEvent::Created { + path: MediaPath::new("photos/img.jpg"), + volume: VolumeId(uuid::Uuid::nil()), + }; + let v = serde_json::to_value(&event).expect("serialize FileEvent::Created"); + assert!(v.is_object(), "FileEvent JSON must be an object"); + assert_eq!( + v["type"], + serde_json::json!("Created"), + "FileEvent must serialize with 'type' discriminant key" + ); + // Inline tag: path and volume live at the top level, no 'data' wrapper. + assert!( + v["path"].is_string(), + "path must be inlined at the top level" + ); + assert_eq!(v["path"], serde_json::json!("photos/img.jpg")); + assert!( + v["volume"].is_string(), + "volume must be inlined at the top level" + ); + assert_eq!( + v["volume"], + serde_json::json!("00000000-0000-0000-0000-000000000000") + ); +} + +// ── E2: AppEvent + InvalidationReason wire-shape tests ───────────────────── +// WHY: AppEvent uses #[serde(tag = "kind", content = "data")] matching +// CoreError's discriminated-union shape. Inner FileEvent keeps its own +// #[serde(tag = "type")] inline shape. Pinning both here catches any +// serde-tag drift before it silently breaks the frontend. + +#[test] +fn app_event_file_wraps_file_event_with_kind_data() { + let event = AppEvent::File(FileEvent::Created { + path: MediaPath::new("photos/img.jpg"), + volume: VolumeId(uuid::Uuid::nil()), + }); + let v: serde_json::Value = serde_json::to_value(&event).expect("serialize"); + assert_eq!(v["kind"], "File"); + assert_eq!(v["data"]["type"], "Created"); + assert_eq!(v["data"]["path"], "photos/img.jpg"); +} + +#[test] +fn app_event_scan_completed_serializes_with_named_fields() { + let event = AppEvent::ScanCompleted { + volume: VolumeId(uuid::Uuid::nil()), + files_seen: 42, + files_new: 7, + duration_ms: 1234, + }; + let v: serde_json::Value = serde_json::to_value(&event).expect("serialize"); + assert_eq!(v["kind"], "ScanCompleted"); + assert_eq!(v["data"]["files_seen"], 42); + assert_eq!(v["data"]["files_new"], 7); + assert_eq!(v["data"]["duration_ms"], 1234); +} + +#[test] +fn app_event_index_invalidated_serializes_with_reason() { + // WHY: InvalidationReason uses default serde enum serialization (no tag + // attribute), so unit variants serialize as bare strings ("TagsChanged"). + // AppEvent::IndexInvalidated { reason } with tag="kind" content="data" + // produces: {"kind":"IndexInvalidated","data":{"reason":"TagsChanged"}}. + let event = AppEvent::IndexInvalidated { + reason: InvalidationReason::TagsChanged, + }; + let v: serde_json::Value = serde_json::to_value(&event).expect("serialize"); + assert_eq!(v["kind"], "IndexInvalidated"); + assert_eq!(v["data"]["reason"], "TagsChanged"); +} diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml index d780f8d..455babc 100644 --- a/crates/db/Cargo.toml +++ b/crates/db/Cargo.toml @@ -15,11 +15,27 @@ thiserror.workspace = true tracing.workspace = true uuid.workspace = true chrono.workspace = true +flume.workspace = true +r2d2.workspace = true +r2d2_sqlite.workspace = true + +[features] +default = [] +# WHY: gates the shared `test_utils` module so production builds don't +# expose NoopBus + future test scaffolding. Crates that need NoopBus in +# their dev-dependencies enable this feature on perima-db. +test-utils = [] [dev-dependencies] tempfile.workspace = true blake3.workspace = true proptest.workspace = true +# WHY self-dep: integration tests live in `tests/` and consume perima-db +# as an external crate, so they need the `test-utils` feature enabled +# explicitly. Cargo's dev-dependency self-reference is the canonical +# pattern for this (used by serde, tokio, etc.) — it's surprising the +# first time you see it but standard. +perima-db = { path = ".", features = ["test-utils"] } [lints] workspace = true diff --git a/crates/db/migrations/V009__hlc_columns.sql b/crates/db/migrations/V009__hlc_columns.sql new file mode 100644 index 0000000..a4b391f --- /dev/null +++ b/crates/db/migrations/V009__hlc_columns.sql @@ -0,0 +1,22 @@ +-- V009: HLC (Hybrid Logical Clock) columns on CRDT-eligible rows. +-- +-- WHY: per architecture audit §4.8, lock in the ordering primitive +-- needed for any post-v1 CRDT integration (Loro, Automerge, Yrs) +-- BEFORE the integration happens. Additive, nullable — populated +-- lazily by Batch B+ writer paths. No change to existing `updated_at` +-- semantics. +-- +-- Exclusions: `volume_mounts`, `scan_progress`, `thumbnail_queue`, +-- `device_config` are intrinsically device-local (machine_id scoped) +-- and never synced — no hlc column. +-- +-- Packing: 48 low-bits ms + 16 high-bits counter → non-negative i64. +-- Ord over i64 matches Ord over (ms, counter). See +-- `crates/core::Hlc` for the helper. + +ALTER TABLE files ADD COLUMN hlc INTEGER; +ALTER TABLE file_locations ADD COLUMN hlc INTEGER; +ALTER TABLE file_metadata ADD COLUMN hlc INTEGER; +ALTER TABLE tags ADD COLUMN hlc INTEGER; +ALTER TABLE file_tags ADD COLUMN hlc INTEGER; +ALTER TABLE volumes ADD COLUMN hlc INTEGER; diff --git a/crates/db/migrations/V010__shared_doc.sql b/crates/db/migrations/V010__shared_doc.sql new file mode 100644 index 0000000..e0bf562 --- /dev/null +++ b/crates/db/migrations/V010__shared_doc.sql @@ -0,0 +1,16 @@ +-- V010: Reserve shared_doc table for post-v1 Loro integration. +-- +-- WHY: per architecture audit §4.8 pre-v1 action 2. Empty table; +-- exact shape prescribed by the audit. Locking the shape NOW lets +-- the post-v1 integration skip a schema break. Loro will persist +-- one doc per library (tag taxonomy, collections, saved searches, +-- reference-board layouts, edit decision lists) — the subset of +-- shared state where CRDT semantics earn their cost. + +CREATE TABLE shared_doc ( + id TEXT PRIMARY KEY, + snapshot BLOB, + version_vector BLOB, + updated_at INTEGER NOT NULL, + hlc INTEGER +); diff --git a/crates/db/src/cmd.rs b/crates/db/src/cmd.rs new file mode 100644 index 0000000..5320f5c --- /dev/null +++ b/crates/db/src/cmd.rs @@ -0,0 +1,306 @@ +//! Write-command envelope for the `SQLite` writer actor. +//! +//! Each sub-enum variant carries a [`flume::Sender`] reply channel +//! (`bounded(1)`). Sub-enums are `#[non_exhaustive]` + empty in Task 1 +//! and populated incrementally per repo in Tasks 2-6. +//! +//! WHY `flume::Sender` over `tokio::sync::oneshot::Sender`: +//! `oneshot::Receiver::blocking_recv` panics inside a tokio runtime +//! context ("Cannot block the current thread from within a runtime"). +//! `flume::Receiver::recv` is runtime-agnostic and works in sync OR +//! async callers. A single `flume` dep also covers the command channel. + +use std::path::PathBuf; + +use flume::Sender; + +use perima_core::{ + BlakeHash, CoreError, DeviceId, HashedFile, LocationStatus, MediaMetadata, MediaPath, Tag, + UpsertOutcome, VolumeId, VolumeIdentifiers, +}; +use uuid::Uuid; + +/// Reply channel alias for writer-to-caller responses. +pub type ReplyTx = Sender>; + +/// Top-level write-command envelope consumed by [`crate::SqliteWriter`]. +/// +/// Each variant wraps a per-repo sub-enum carrying the actual SQL +/// payload and a reply channel. Tasks 2-6 populate the sub-enums. +#[derive(Debug)] +#[non_exhaustive] +pub enum WriteCmd { + /// Volume-repo writes (populated Task 2). + Volume(VolumeWriteCmd), + /// Tag-repo writes (populated Task 3). + Tag(TagWriteCmd), + /// Metadata-repo writes (populated Task 4). + Metadata(MetadataWriteCmd), + /// File-repo writes (populated Task 5). + File(FileWriteCmd), + /// Search-repo writes (populated Task 6). + Search(SearchWriteCmd), +} + +/// Volume-repo write commands. Populated by Task 2. +/// +/// WHY `ReplyTx` carries `Debug`: `flume::Sender` implements `Debug` +/// (verified crate docs), and every payload type on the in-flight side +/// (`VolumeIdentifiers`, `DeviceId`, `VolumeId`, `PathBuf`) is `Debug`. +/// Keeping `#[derive(Debug)]` on this enum lets the writer loop's +/// `tracing::debug!` prints render a full command view without a manual +/// impl. +#[derive(Debug)] +pub enum VolumeWriteCmd { + /// Find an existing volume matching `identifiers` or insert a new + /// row. Updates `last_seen` + `updated_at` + `device_id` on match. + FindOrCreate { + /// Observed identifiers (GUID / `fs_uuid` / label+capacity). + identifiers: VolumeIdentifiers, + /// Device that observed the volume. + device: DeviceId, + /// Reply channel carrying the resolved [`VolumeId`]. + reply: ReplyTx, + }, + /// Record (or refresh) the current mount path for `(volume, device)`. + RecordMount { + /// Volume being mounted. + volume: VolumeId, + /// Device that observed the mount. + device: DeviceId, + /// Absolute mount-point path on the device. + mount: PathBuf, + /// Reply channel acknowledging the write. + reply: ReplyTx<()>, + }, +} + +/// Tag-repo write commands. Populated by Task 3. +/// +/// WHY `ReplyTx` on `Attach` / `Detach` / `DeleteTag` (not `()`): +/// the writer learns `rusqlite::Connection::changes()` after each UPDATE +/// / INSERT, and callers occasionally want "did anything actually +/// happen?" signal. The current [`perima_core::TagRepository`] port +/// returns `Result<(), CoreError>` on attach/detach/delete, so the +/// adapter drops the `u64` on the floor today; keeping the wider reply +/// channel now means surfacing the count later is a port-only change. +#[derive(Debug)] +pub enum TagWriteCmd { + /// Insert a new tag row (or look up an existing active one by + /// normalized name). Returns the resolved [`Tag`]. + /// + /// Name normalization is performed by the adapter before the command + /// is sent — the writer receives the already-normalized `name`. + UpsertTag { + /// Normalized tag name (post `perima_core::normalize_tag`). + name: String, + /// Device that initiated the upsert. + device: DeviceId, + /// Reply channel carrying the resolved [`Tag`]. + reply: ReplyTx, + }, + /// Soft-delete a tag by id. Attachments in `file_tags` survive — + /// CRDT semantics preserve link history (see port trait doc). + DeleteTag { + /// Tag UUID. + tag_id: Uuid, + /// Device that initiated the delete. + device: DeviceId, + /// Reply channel carrying `rows_changed` (0 if already deleted). + reply: ReplyTx, + }, + /// Attach a tag to a content hash. Idempotent at the DB level — + /// re-attaching an already-active `(hash, tag_id)` pair is a no-op. + Attach { + /// Content hash. + hash: BlakeHash, + /// Tag UUID. + tag_id: Uuid, + /// Device that initiated the attach. + device: DeviceId, + /// Reply channel carrying `rows_changed` (1 on new insert, 0 on + /// idempotent repeat). + reply: ReplyTx, + }, + /// Soft-delete the `file_tags` row linking `hash` → `tag_id`. + Detach { + /// Content hash. + hash: BlakeHash, + /// Tag UUID. + tag_id: Uuid, + /// Device that initiated the detach. + device: DeviceId, + /// Reply channel carrying `rows_changed` (0 if nothing was + /// active for the pair). + reply: ReplyTx, + }, +} + +/// Metadata-repo write commands. Populated by Task 4. +/// +/// WHY `UpdateThumbnail` sits alongside `UpsertMetadata` (rather than +/// being folded into the latter): the thumbnail-worker path writes +/// independently from the extractor path — same row, different logical +/// event. `upsert_metadata`'s Unchanged/Updated equivalence proxy +/// compares `device_id` + `mime_type` only, so a thumbnail status flip +/// `pending → ready` would be classified Unchanged and lost. Mirror of +/// the pre-Batch-C `MetadataRepository::update_thumbnail` rationale. +#[derive(Debug)] +pub enum MetadataWriteCmd { + /// Insert or update the metadata row keyed by `record.hash`. + /// + /// Thumbnail columns are deliberately NOT touched by this command — + /// see `crates/db/src/writer/metadata.rs::upsert_metadata_impl` for + /// the decoupling rationale (utof/perima#15 HIGH #4). + UpsertMetadata { + /// Metadata to persist. The `hash` field is the content- + /// addressed PK; every other field is nullable / optional. + record: MediaMetadata, + /// Device that initiated the upsert. + device: DeviceId, + /// Reply channel carrying the classification + /// (Inserted / Updated / Unchanged). + reply: ReplyTx, + }, + /// Update the thumbnail columns on an existing `file_metadata` row. + /// + /// `path` is carried as `Option` (nullable in SQL) — + /// thumbnail-failed rows store `path = NULL` with `status = + /// "failed"`. The writer transmits `Option` rather than + /// `Option<&str>` because the command crosses a thread boundary + /// via `flume`; `'static` is the simplest lifetime contract. + UpdateThumbnail { + /// Content hash of the file whose thumbnail row to update. + hash: BlakeHash, + /// Thumbnail path (WebP under the thumbnail root) or `None` + /// when the generation failed. + path: Option, + /// Status literal — one of `pending` / `ready` / `failed`. + status: String, + /// Device that initiated the update. + device: DeviceId, + /// Reply channel carrying `rows_changed` (0 if no metadata + /// row exists for `hash`; 1 otherwise). + reply: ReplyTx, + }, +} + +/// File-repo write commands. Populated by Task 5. +/// +/// WHY `UpsertFile` reply is `ReplyTx` (not `()`): +/// `ScanUseCase` classifies Inserted / Updated / Unchanged to decide +/// whether to trigger downstream thumbnail generation and metadata +/// extraction. The outcome signal must cross the writer boundary. +/// +/// WHY three inherent-method variants (`UpdateLocationStatus`, +/// `UpdateLocationPath`, `MigrateSentinelRow`) sit alongside the two +/// port-trait variants: `DbEventHandler` (desktop + CLI) calls them on +/// `Arc` without going through the `FileRepository` +/// trait. Keeping them as `WriteCmd` variants means a single writer-actor +/// serializes ALL writes to `file_locations`, including the watcher's +/// status flips — no second writable connection needed. +#[derive(Debug)] +pub enum FileWriteCmd { + /// Upsert the content-addressed `files` row keyed by `file.hash`. + /// + /// Binds `files.hlc` on INSERT and on UPDATE. Skips all writes on + /// Unchanged (prior `hlc` is preserved). + UpsertFile { + /// Hashed file (hash + discovered metadata). + file: HashedFile, + /// Device that initiated the upsert. + device: DeviceId, + /// Reply channel carrying `Inserted / Updated / Unchanged`. + reply: ReplyTx, + }, + /// Upsert a `file_locations` row for `(volume, path)`. + /// + /// Binds `file_locations.hlc` on INSERT, UPDATE, and the + /// collision-path soft-delete. Skips writes on Unchanged. + UpsertLocation { + /// Content hash linking to the `files` row. + hash: BlakeHash, + /// Volume the location lives on. + volume: VolumeId, + /// Relative path within the volume. + path: MediaPath, + /// Device that initiated the upsert. + device: DeviceId, + /// Reply channel carrying `Inserted / Updated / Unchanged`. + reply: ReplyTx, + }, + /// Update the status of a non-deleted `file_locations` row. + /// + /// WHY inherent (not port-trait): called by `DbEventHandler` in + /// response to already-emitted `FileEvent`s from the filesystem + /// watcher; not part of the `FileRepository` port surface. + /// Binds `file_locations.hlc` on the UPDATE. + UpdateLocationStatus { + /// Volume the location lives on. + volume: VolumeId, + /// Relative path within the volume. + path: MediaPath, + /// New status value. + status: LocationStatus, + /// Device that initiated the update. + device: DeviceId, + /// Reply channel carrying `rows_changed` (`0` or `1`). + reply: ReplyTx, + }, + /// Rename a `file_locations` row and reset status to `active`. + /// + /// WHY inherent: called by `DbEventHandler` on + /// `FileEvent::Renamed`. Binds `file_locations.hlc` on the UPDATE + /// or collision-path soft-delete. + UpdateLocationPath { + /// Volume the location lives on. + volume: VolumeId, + /// Current (old) relative path. + old_path: MediaPath, + /// Target (new) relative path. + new_path: MediaPath, + /// Device that initiated the update. + device: DeviceId, + /// Reply channel carrying `rows_changed` (`0` or `1`). + reply: ReplyTx, + }, + /// Migrate a sentinel (`volume_id = nil-UUID`) `file_locations` row + /// to the real volume after scan phase 1c resolves the volume. + /// + /// WHY inherent: called by the scan sentinel-migration closure + /// (CLI `dispatch_scan` + desktop scan path). Not part of the + /// `FileRepository` port surface. Binds `file_locations.hlc` on + /// the UPDATE. + MigrateSentinelRow { + /// Relative path of the sentinel row to migrate. + path: MediaPath, + /// Resolved real volume to assign. + real_volume: VolumeId, + /// Device that initiated the migration. + device: DeviceId, + /// Reply channel carrying `rows_changed` (`0` or `1`). + reply: ReplyTx, + }, +} + +/// Search-repo write commands. Populated by Task 6. +/// +/// WHY only one variant: `search` is read-only (pool); `rebuild` is the +/// sole write path — wipe + reseed the FTS5 index from source rows. +/// Per-row FTS maintenance runs via `SQLite` triggers on `file_metadata` / +/// `file_tags` / `file_locations`; those fires are captured inside the +/// respective Task 3-5 writer handlers. No additional write variants are +/// needed for search. +#[derive(Debug)] +pub enum SearchWriteCmd { + /// Drop + reseed the FTS5 index (`search_content` table) from scratch. + /// + /// WHY `ReplyTx<()>`: the caller blocks until the rebuild completes + /// (CLI `perima search --rebuild`; Desktop `search_rebuild` command). + /// The result carries no payload — either it succeeded (`Ok(())`) or + /// propagated a [`perima_core::CoreError`]. + Rebuild { + /// Reply channel; writer sends `Ok(())` on success. + reply: ReplyTx<()>, + }, +} diff --git a/crates/db/src/file_repo.rs b/crates/db/src/file_repo.rs index 2540deb..c86e9a5 100644 --- a/crates/db/src/file_repo.rs +++ b/crates/db/src/file_repo.rs @@ -1,27 +1,34 @@ -//! `FileRepository` implementation backed by rusqlite. - -use std::sync::Mutex; - +//! `FileRepository` adapter — writer-actor + read-pool backed. +//! +//! Post-Batch-C Task 7. The struct holds two cheap-to-clone handles: +//! a [`flume::Sender`] connected to the single writer actor +//! (spec §3.1) and a [`ReadPool`] of read-only `r2d2_sqlite` +//! connections (spec §3.4). Writes build a [`FileWriteCmd`] variant with +//! a `flume::bounded(1)` reply channel and block on the reply. Reads +//! run SQL directly against a pooled connection. +//! +//! No `Mutex`. Every caller now supplies +//! `(writer_sender, read_pool)` via `SqliteFileRepository::new`. + +use flume::Sender; use perima_core::{ BlakeHash, CoreError, DeviceId, FileLocationRecord, FileRepository, FileSize, HashedFile, LocationStatus, MediaPath, UpsertOutcome, VolumeId, }; use rusqlite::Connection; -// WHY: OptionalExtension adds `.optional()` to query_row results, converting -// QueryReturnedNoRows into Ok(None) for our two-statement SELECT-then-upsert pattern. -use rusqlite::OptionalExtension; +use crate::cmd::{FileWriteCmd, WriteCmd}; use crate::errors::Error; +use crate::pool::ReadPool; -/// Rusqlite-backed file + location repository. +/// Writer-actor + read-pool backed file + location repository. /// -/// WHY `Mutex`: `rusqlite::Connection` is `Send` but not `Sync` (internal -/// `RefCell` state). The `FileRepository` trait requires `Send + Sync` so -/// callers can store implementations in `Arc`. Wrapping -/// in `Mutex` makes the struct satisfy both bounds without `unsafe`. -/// All DB methods lock briefly; there is no blocking I/O inside the lock. +/// Cheap to [`Clone`]: both fields (`flume::Sender`, `ReadPool`) are +/// internally refcounted. +#[derive(Clone)] pub struct SqliteFileRepository { - conn: Mutex, + writer: Sender, + reads: ReadPool, } impl std::fmt::Debug for SqliteFileRepository { @@ -32,28 +39,20 @@ impl std::fmt::Debug for SqliteFileRepository { } impl SqliteFileRepository { - /// Wrap an existing connection. The caller must have run - /// migrations before constructing this. - pub const fn new(conn: Connection) -> Self { - Self { - conn: Mutex::new(conn), - } + /// Construct an adapter from a writer-command sender + a read pool. + /// + /// WHY no migration run here: migrations happen exactly once inside + /// [`crate::SqliteWriter::start`] BEFORE the writer thread spawns + /// (spec §3.6). The read pool is opened after migrations complete. + #[must_use] + pub const fn new(writer: Sender, reads: ReadPool) -> Self { + Self { writer, reads } } } -fn now_iso() -> String { - chrono::Utc::now().to_rfc3339() -} - -/// Convert `FileSize` (`u64`) to the `i64` that `SQLite` stores. -/// -/// WHY: `SQLite` integers are signed 64-bit. A file larger than `i64::MAX` -/// (~8 EiB) cannot exist on current hardware; we propagate as `Internal` -/// rather than silently wrapping. -fn size_to_i64(size: FileSize) -> Result { - i64::try_from(size.0) - .map_err(|_| CoreError::Internal(format!("file size {} overflows i64", size.0))) -} +// --------------------------------------------------------------------------- +// Helpers (read path) +// --------------------------------------------------------------------------- /// Convert the `i64` stored in `SQLite` back to `FileSize`. /// @@ -75,6 +74,10 @@ fn limit_to_i64(limit: usize) -> i64 { i64::try_from(limit).unwrap_or(i64::MAX) } +// --------------------------------------------------------------------------- +// Inherent methods (writer-actor shim variants) +// --------------------------------------------------------------------------- + impl SqliteFileRepository { /// Migrate a sentinel row from phase 1b to the real `volume`. /// @@ -89,62 +92,27 @@ impl SqliteFileRepository { /// Returns the number of rows updated (0 if no sentinel row existed). /// /// # Errors - /// `CoreError::Internal` on DB or mutex failure. - // WHY allow(significant_drop_tightening): the Mutex guard `conn` must - // outlive the `execute` call that borrows through it. This is the same - // pattern used throughout this file. - #[allow(clippy::significant_drop_tightening)] + /// `CoreError::Internal` on DB failure. pub fn migrate_sentinel_row( &self, path: &MediaPath, real_volume: VolumeId, device: DeviceId, ) -> Result { - let conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; - let now = now_iso(); - let vol_str = real_volume.0.to_string(); - let dev_str = device.0.to_string(); - let path_str = path.as_str(); - // WHY: nil UUID string literal is hard-coded here because this method - // is the *only* place we intentionally touch sentinel rows. Using a - // constant avoids importing VolumeId into a string constant but keeps - // the magic value visible and auditable. - let n = conn - .execute( - "UPDATE file_locations - SET volume_id = ?1, updated_at = ?2, device_id = ?3 - WHERE volume_id = '00000000-0000-0000-0000-000000000000' - AND relative_path = ?4 AND deleted_at IS NULL", - rusqlite::params![vol_str, now, dev_str, path_str], - ) - .map_err(Error::from)?; - // WHY: `rusqlite::Connection::execute` returns `usize`; the schema - // guarantees at most 1 sentinel row per path, so the cast to u64 is - // safe on all supported platforms. - #[allow(clippy::cast_possible_truncation)] - Ok(n as u64) + let (reply_tx, reply_rx) = flume::bounded::>(1); + self.writer + .send(WriteCmd::File(FileWriteCmd::MigrateSentinelRow { + path: path.clone(), + real_volume, + device, + reply: reply_tx, + })) + .map_err(|e| CoreError::Internal(format!("writer send: {e}")))?; + reply_rx + .recv() + .map_err(|e| CoreError::Internal(format!("writer recv: {e}")))? } -} - -/// Convert a `LocationStatus` to its DB string representation. -/// -/// WHY: status values are stored as lowercase strings so they are human-readable -/// in `SQLite` tooling and stable across future Rust refactors (an enum discriminant -/// index would shift if variants were reordered). This is the single source of -/// truth for the mapping; the deserializer in `list_file_locations` mirrors it. -const fn status_to_str(status: LocationStatus) -> &'static str { - match status { - LocationStatus::Active => "active", - LocationStatus::Missing => "missing", - LocationStatus::Moved => "moved", - LocationStatus::Stale => "stale", - } -} -impl SqliteFileRepository { /// Update the status of a non-deleted file location identified by /// `(volume, path)`. /// @@ -152,10 +120,7 @@ impl SqliteFileRepository { /// 1 on success). /// /// # Errors - /// `CoreError::Internal` on DB or mutex failure. - // WHY allow(significant_drop_tightening): the Mutex guard `conn` must - // outlive the `execute` call that borrows through it. - #[allow(clippy::significant_drop_tightening)] + /// `CoreError::Internal` on DB failure. pub fn update_location_status( &self, volume: VolumeId, @@ -163,26 +128,19 @@ impl SqliteFileRepository { status: LocationStatus, device: DeviceId, ) -> Result { - let conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; - let vol_str = volume.0.to_string(); - let path_str = path.as_str(); - let status_str = status_to_str(status); - let dev_str = device.0.to_string(); - let now = now_iso(); - let n = conn - .execute( - "UPDATE file_locations - SET status = ?1, updated_at = ?2, device_id = ?3 - WHERE volume_id = ?4 AND relative_path = ?5 AND deleted_at IS NULL", - rusqlite::params![status_str, now, dev_str, vol_str, path_str], - ) - .map_err(Error::from)?; - // WHY: at most 1 active row per (volume, path) by app-level invariant. - #[allow(clippy::cast_possible_truncation)] - Ok(n as u64) + let (reply_tx, reply_rx) = flume::bounded::>(1); + self.writer + .send(WriteCmd::File(FileWriteCmd::UpdateLocationStatus { + volume, + path: path.clone(), + status, + device, + reply: reply_tx, + })) + .map_err(|e| CoreError::Internal(format!("writer send: {e}")))?; + reply_rx + .recv() + .map_err(|e| CoreError::Internal(format!("writer recv: {e}")))? } /// Update the relative path of a non-deleted file location and reset its @@ -199,10 +157,7 @@ impl SqliteFileRepository { /// 1 if either the source was updated OR soft-deleted on collision). /// /// # Errors - /// `CoreError::Internal` on DB or mutex failure. - // WHY allow(significant_drop_tightening): the Mutex guard `conn` must - // outlive the transaction that borrows through it. - #[allow(clippy::significant_drop_tightening)] + /// `CoreError::Internal` on DB failure. pub fn update_location_path( &self, volume: VolumeId, @@ -210,117 +165,42 @@ impl SqliteFileRepository { new_path: &MediaPath, device: DeviceId, ) -> Result { - let mut conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; - let vol_str = volume.0.to_string(); - let old_str = old_path.as_str(); - let new_str = new_path.as_str(); - let dev_str = device.0.to_string(); - let now = now_iso(); - - // WHY BEGIN IMMEDIATE: the collision check + UPDATE/soft-delete - // sequence must serialize across connections. A concurrent upsert - // at `new_path` could otherwise slip between our SELECT and our - // UPDATE, violating the "exactly one active row per (vol, path)" - // invariant. - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(Error::from)?; - - // Check whether an active row already exists at `new_path`. - let collision: Option = tx - .query_row( - "SELECT id FROM file_locations - WHERE volume_id = ?1 AND relative_path = ?2 AND deleted_at IS NULL", - rusqlite::params![vol_str, new_str], - |row| row.get(0), - ) - .optional() - .map_err(Error::from)?; - - let n = if collision.is_some() { - // WHY: destination wins. Soft-delete the source row so the - // invariant "1 active row per (vol, path)" holds. CRDT-friendly: - // no hard delete, deleted_at/updated_at/device_id all stamped. - tx.execute( - "UPDATE file_locations - SET deleted_at = ?1, updated_at = ?1, device_id = ?2 - WHERE volume_id = ?3 AND relative_path = ?4 AND deleted_at IS NULL", - rusqlite::params![now, dev_str, vol_str, old_str], - ) - .map_err(Error::from)? - } else { - tx.execute( - "UPDATE file_locations - SET relative_path = ?1, status = 'active', updated_at = ?2, device_id = ?3 - WHERE volume_id = ?4 AND relative_path = ?5 AND deleted_at IS NULL", - rusqlite::params![new_str, now, dev_str, vol_str, old_str], - ) - .map_err(Error::from)? - }; - - tx.commit().map_err(Error::from)?; - // WHY: at most 1 active row per (volume, path) by app-level invariant, - // so `n` is always 0 or 1. Cast to u64 for the public contract. - #[allow(clippy::cast_possible_truncation)] - Ok(n as u64) + let (reply_tx, reply_rx) = flume::bounded::>(1); + self.writer + .send(WriteCmd::File(FileWriteCmd::UpdateLocationPath { + volume, + old_path: old_path.clone(), + new_path: new_path.clone(), + device, + reply: reply_tx, + })) + .map_err(|e| CoreError::Internal(format!("writer send: {e}")))?; + reply_rx + .recv() + .map_err(|e| CoreError::Internal(format!("writer recv: {e}")))? } } +// --------------------------------------------------------------------------- +// FileRepository trait impl +// --------------------------------------------------------------------------- + impl FileRepository for SqliteFileRepository { fn upsert_file(&self, file: &HashedFile, device: DeviceId) -> Result { - // WHY: PoisonError can only occur if a thread panicked while holding - // the lock. In that case the DB state is unknown; propagate as Internal. - let conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; - let hash_hex = file.hash.to_hex(); - let now = now_iso(); - let dev_str = device.0.to_string(); - let size_i64 = size_to_i64(file.discovered.size)?; - - // WHY: two-statement SELECT-then-INSERT/UPDATE because - // `SQLite`'s changes() cannot distinguish a fresh INSERT from - // a conflict-triggered UPDATE — both report 1. - let existing: Option<(i64, String)> = conn - .query_row( - "SELECT file_size, device_id FROM files WHERE blake3_hash = ?1", - [&hash_hex], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional() - .map_err(Error::from)?; - - let outcome = match existing { - None => { - conn.execute( - "INSERT INTO files (blake3_hash, file_size, first_seen, updated_at, device_id) - VALUES (?1, ?2, ?3, ?3, ?4)", - rusqlite::params![hash_hex, size_i64, now, dev_str], - ) - .map_err(Error::from)?; - UpsertOutcome::Inserted - } - Some((existing_size, existing_dev)) - if existing_size == size_i64 && existing_dev == dev_str => - { - UpsertOutcome::Unchanged - } - Some(_) => { - conn.execute( - "UPDATE files SET file_size = ?1, updated_at = ?2, device_id = ?3 - WHERE blake3_hash = ?4", - rusqlite::params![size_i64, now, dev_str, hash_hex], - ) - .map_err(Error::from)?; - UpsertOutcome::Updated - } - }; - drop(conn); - Ok(outcome) + let (reply_tx, reply_rx) = flume::bounded::>(1); + // WHY clone `file`: the command crosses a thread boundary via + // `flume::Sender::send`, which requires `'static`. `HashedFile` + // is `Clone` (shallow: hash + path + size). + self.writer + .send(WriteCmd::File(FileWriteCmd::UpsertFile { + file: file.clone(), + device, + reply: reply_tx, + })) + .map_err(|e| CoreError::Internal(format!("writer send: {e}")))?; + reply_rx + .recv() + .map_err(|e| CoreError::Internal(format!("writer recv: {e}")))? } fn upsert_location( @@ -330,174 +210,152 @@ impl FileRepository for SqliteFileRepository { path: &MediaPath, device: DeviceId, ) -> Result { - let mut conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; - let hash_hex = hash.to_hex(); - let vol_str = volume.0.to_string(); - let path_str = path.as_str(); - let dev_str = device.0.to_string(); - let now = now_iso(); - - // WHY BEGIN IMMEDIATE: SQLite serializes statements but not - // read-modify-write sequences. Two concurrent scanners SELECTing - // "not found" would both INSERT and produce duplicate active rows - // for the same (volume, path). IMMEDIATE grabs the writer lock at - // BEGIN; the busy_timeout installed by `open_and_migrate` makes - // the second writer wait instead of erroring with SQLITE_BUSY. - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(Error::from)?; - - // WHY: app-level uniqueness on (volume_id, relative_path, - // deleted_at IS NULL) replaces a UNIQUE constraint that - // CLAUDE.md forbids on mutable columns. Safe under IMMEDIATE. - let existing: Option<(String, String, String)> = tx - .query_row( - "SELECT id, blake3_hash, device_id FROM file_locations - WHERE volume_id = ?1 AND relative_path = ?2 AND deleted_at IS NULL", - rusqlite::params![vol_str, path_str], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .optional() - .map_err(Error::from)?; - - let outcome = match existing { - None => { - let id = perima_core::ids::new_id().to_string(); - tx.execute( - "INSERT INTO file_locations - (id, blake3_hash, volume_id, relative_path, status, - first_seen, updated_at, device_id) - VALUES (?1, ?2, ?3, ?4, 'active', ?5, ?5, ?6)", - rusqlite::params![id, hash_hex, vol_str, path_str, now, dev_str], - ) - .map_err(Error::from)?; - UpsertOutcome::Inserted - } - Some((_, ref existing_hash, ref existing_dev)) - if *existing_hash == hash_hex && *existing_dev == dev_str => - { - UpsertOutcome::Unchanged - } - Some((ref row_id, _, _)) => { - tx.execute( - "UPDATE file_locations - SET blake3_hash = ?1, updated_at = ?2, device_id = ?3 - WHERE id = ?4", - rusqlite::params![hash_hex, now, dev_str, row_id], - ) - .map_err(Error::from)?; - UpsertOutcome::Updated - } - }; - tx.commit().map_err(Error::from)?; - drop(conn); - Ok(outcome) + let (reply_tx, reply_rx) = flume::bounded::>(1); + self.writer + .send(WriteCmd::File(FileWriteCmd::UpsertLocation { + hash: *hash, + volume, + path: path.clone(), + device, + reply: reply_tx, + })) + .map_err(|e| CoreError::Internal(format!("writer send: {e}")))?; + reply_rx + .recv() + .map_err(|e| CoreError::Internal(format!("writer recv: {e}")))? } - // WHY allow(significant_drop_tightening): the Mutex guard `conn` must - // outlive `stmt` and `rows` because they borrow through the guard. - // Dropping `conn` after `rows` is fully consumed is already optimal; - // Clippy's suggested rewrite would break the borrow graph. - #[allow(clippy::significant_drop_tightening)] fn list_file_locations( &self, limit: usize, volume: Option, ) -> Result, CoreError> { - let conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; - // WHY separate SQL strings per branch instead of `(?1 IS NULL OR fl.volume_id = ?1)`: - // the OR-with-NULL predicate defeats index use on `idx_file_locations_volume_path`; - // EXPLAIN QUERY PLAN reports SCAN + TEMP B-TREE sort even when a concrete - // volume_id is supplied. Branching here keeps both shapes index-eligible. - let vol_filter = volume.map(|v| v.0.to_string()); - let sql: &str = if vol_filter.is_some() { - "SELECT f.blake3_hash, f.file_size, fl.volume_id, fl.relative_path, - fl.status, fl.first_seen - FROM file_locations fl - JOIN files f ON f.blake3_hash = fl.blake3_hash - WHERE fl.deleted_at IS NULL AND fl.volume_id = ?1 - ORDER BY fl.relative_path - LIMIT ?2" - } else { - "SELECT f.blake3_hash, f.file_size, fl.volume_id, fl.relative_path, - fl.status, fl.first_seen - FROM file_locations fl - JOIN files f ON f.blake3_hash = fl.blake3_hash - WHERE fl.deleted_at IS NULL - ORDER BY fl.relative_path - LIMIT ?1" - }; - let mut stmt = conn.prepare(sql).map_err(Error::from)?; + // WHY pool-only (no writer hop): `list_file_locations` is a + // pure SELECT. Reads go directly through the `r2d2_sqlite` pool + // (spec §3.5). `PooledConnection` derefs to + // `rusqlite::Connection`, so the SQL body is lifted verbatim + // from the pre-Batch-C impl. + let conn = self.reads.get()?; + list_file_locations_sql(&conn, limit, volume) + } +} - let limit_i64 = limit_to_i64(limit); - let mut params: Vec> = Vec::new(); - if let Some(v) = vol_filter.as_deref() { - params.push(Box::new(v.to_owned())); - } - params.push(Box::new(limit_i64)); - - let rows = stmt - .query_map(rusqlite::params_from_iter(params.iter()), |row| { - let hash_hex: String = row.get(0)?; - let size: i64 = row.get(1)?; - let vol_str: String = row.get(2)?; - let rel_path: String = row.get(3)?; - let status_str: String = row.get(4)?; - let first_seen: String = row.get(5)?; - Ok((hash_hex, size, vol_str, rel_path, status_str, first_seen)) - }) - .map_err(Error::from)?; - - let mut out = Vec::new(); - for row in rows { - let (hash_hex, size, vol_str, rel_path, status_str, first_seen) = - row.map_err(Error::from)?; - let hash = BlakeHash::parse_hex(&hash_hex)?; - let volume_id = VolumeId( - uuid::Uuid::parse_str(&vol_str) - .map_err(|e| CoreError::Internal(format!("bad volume uuid: {e}")))?, - ); - let status = match status_str.as_str() { - "active" => LocationStatus::Active, - "missing" => LocationStatus::Missing, - "moved" => LocationStatus::Moved, - "stale" => LocationStatus::Stale, - other => { - return Err(CoreError::Internal(format!( - "unknown location status: {other}" - ))); - } - }; - out.push(FileLocationRecord { - hash, - size: i64_to_size(size)?, - volume_id, - relative_path: MediaPath::new(&rel_path), - status, - first_seen, - }); - } - Ok(out) +/// Shared SELECT body for `list_file_locations`. +/// +/// WHY separate function: factored out for clarity and potential reuse. +fn list_file_locations_sql( + conn: &Connection, + limit: usize, + volume: Option, +) -> Result, CoreError> { + // WHY separate SQL strings per branch instead of `(?1 IS NULL OR fl.volume_id = ?1)`: + // the OR-with-NULL predicate defeats index use on `idx_file_locations_volume_path`; + // EXPLAIN QUERY PLAN reports SCAN + TEMP B-TREE sort even when a concrete + // volume_id is supplied. Branching here keeps both shapes index-eligible. + let vol_filter = volume.map(|v| v.0.to_string()); + let sql: &str = if vol_filter.is_some() { + "SELECT f.blake3_hash, f.file_size, fl.volume_id, fl.relative_path, + fl.status, fl.first_seen + FROM file_locations fl + JOIN files f ON f.blake3_hash = fl.blake3_hash + WHERE fl.deleted_at IS NULL AND fl.volume_id = ?1 + ORDER BY fl.relative_path + LIMIT ?2" + } else { + "SELECT f.blake3_hash, f.file_size, fl.volume_id, fl.relative_path, + fl.status, fl.first_seen + FROM file_locations fl + JOIN files f ON f.blake3_hash = fl.blake3_hash + WHERE fl.deleted_at IS NULL + ORDER BY fl.relative_path + LIMIT ?1" + }; + let mut stmt = conn.prepare(sql).map_err(Error::from)?; + + let limit_i64 = limit_to_i64(limit); + let mut params: Vec> = Vec::new(); + if let Some(v) = vol_filter.as_deref() { + params.push(Box::new(v.to_owned())); + } + params.push(Box::new(limit_i64)); + + let rows = stmt + .query_map(rusqlite::params_from_iter(params.iter()), |row| { + let hash_hex: String = row.get(0)?; + let size: i64 = row.get(1)?; + let vol_str: String = row.get(2)?; + let rel_path: String = row.get(3)?; + let status_str: String = row.get(4)?; + let first_seen: String = row.get(5)?; + Ok((hash_hex, size, vol_str, rel_path, status_str, first_seen)) + }) + .map_err(Error::from)?; + + let mut out = Vec::new(); + for row in rows { + let (hash_hex, size, vol_str, rel_path, status_str, first_seen) = + row.map_err(Error::from)?; + let hash = BlakeHash::parse_hex(&hash_hex)?; + let volume_id = VolumeId( + uuid::Uuid::parse_str(&vol_str) + .map_err(|e| CoreError::Internal(format!("bad volume uuid: {e}")))?, + ); + let status = match status_str.as_str() { + "active" => LocationStatus::Active, + "missing" => LocationStatus::Missing, + "moved" => LocationStatus::Moved, + "stale" => LocationStatus::Stale, + other => { + return Err(CoreError::Internal(format!( + "unknown location status: {other}" + ))); + } + }; + out.push(FileLocationRecord { + hash, + size: i64_to_size(size)?, + volume_id, + relative_path: MediaPath::new(&rel_path), + status, + first_seen, + }); } + Ok(out) } +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[allow( + clippy::unwrap_used, + reason = "tests: unwrap is the assertion — a panic is a failing test by design" +)] #[cfg(test)] mod tests { use std::path::PathBuf; + use std::sync::Arc; + + use perima_core::EventBus; + use tempfile::TempDir; use super::*; - use crate::connection::open_and_migrate; + use crate::pool::ReadPool; + use crate::test_utils::NoopBus; + use crate::writer::{SqliteWriter, SqliteWriterHandle}; - fn test_db() -> (tempfile::TempDir, SqliteFileRepository) { + /// Test harness: tempdir-backed DB, writer actor, read pool, repo. + /// + /// WHY tempfile-on-disk (not in-memory): writer + pool must share + /// the same DB file; `:memory:` is per-connection private. + fn test_db() -> (TempDir, SqliteFileRepository, SqliteWriterHandle) { let td = tempfile::tempdir().expect("tempdir"); - let conn = open_and_migrate(&td.path().join("test.db")).expect("open"); - (td, SqliteFileRepository::new(conn)) + let db_path = td.path().join("test.db"); + let bus: Arc = Arc::new(NoopBus); + let writer = SqliteWriter::start(&db_path, bus).expect("writer start"); + let reads = ReadPool::open(&db_path).expect("pool open"); + let repo = SqliteFileRepository::new(writer.sender(), reads); + (td, repo, writer) } fn sample_hashed_file(content: &[u8], rel_path: &str) -> HashedFile { @@ -522,7 +380,7 @@ mod tests { #[test] fn upsert_file_inserts_new() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let f = sample_hashed_file(b"hello", "a.txt"); let out = repo.upsert_file(&f, device()).expect("upsert"); assert_eq!(out, UpsertOutcome::Inserted); @@ -530,7 +388,7 @@ mod tests { #[test] fn upsert_file_unchanged_on_repeat() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let f = sample_hashed_file(b"hello", "a.txt"); repo.upsert_file(&f, dev).expect("first"); @@ -540,7 +398,7 @@ mod tests { #[test] fn upsert_file_updated_on_size_change() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let f1 = sample_hashed_file(b"hello", "a.txt"); repo.upsert_file(&f1, dev).expect("first"); @@ -553,7 +411,7 @@ mod tests { #[test] fn upsert_location_inserts_new() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let f = sample_hashed_file(b"hello", "a.txt"); repo.upsert_file(&f, dev).expect("file"); @@ -565,7 +423,7 @@ mod tests { #[test] fn upsert_location_unchanged_on_repeat() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let f = sample_hashed_file(b"hello", "a.txt"); repo.upsert_file(&f, dev).expect("file"); @@ -581,7 +439,7 @@ mod tests { #[test] fn upsert_location_updated_on_hash_change() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let f1 = sample_hashed_file(b"hello", "a.txt"); let f2 = sample_hashed_file(b"world", "a.txt"); @@ -599,7 +457,7 @@ mod tests { #[test] fn list_file_locations_returns_all() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let vol = sentinel_volume(); for (i, name) in ["a.txt", "b.txt", "c.txt"].iter().enumerate() { @@ -617,7 +475,7 @@ mod tests { #[test] fn list_file_locations_respects_limit() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let vol = sentinel_volume(); for i in 0..5 { @@ -632,7 +490,7 @@ mod tests { #[test] fn list_file_locations_filters_by_volume() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let vol_a = VolumeId::new(); let vol_b = VolumeId::new(); @@ -651,7 +509,7 @@ mod tests { #[test] fn migrate_sentinel_row_updates_volume_id() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let sentinel = sentinel_volume(); let real_vol = VolumeId::new(); @@ -688,7 +546,7 @@ mod tests { #[test] fn migrate_sentinel_row_skips_non_sentinel() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let real_vol = VolumeId::new(); let other_vol = VolumeId::new(); @@ -710,7 +568,7 @@ mod tests { #[test] fn update_status_to_missing() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let vol = VolumeId::new(); let f = sample_hashed_file(b"missing_test", "img.jpg"); @@ -736,7 +594,7 @@ mod tests { #[test] fn update_status_to_stale() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let vol = VolumeId::new(); let f = sample_hashed_file(b"stale_test", "doc.txt"); @@ -759,7 +617,7 @@ mod tests { #[test] fn update_location_path_renames() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let vol = VolumeId::new(); let f = sample_hashed_file(b"rename_test", "old_name.jpg"); @@ -794,7 +652,7 @@ mod tests { // filesystem already has a file at new_path). Observable: // list_file_locations shows exactly the destination row, with the // destination's original hash untouched. - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let vol = VolumeId::new(); @@ -839,7 +697,7 @@ mod tests { // 1b edit. A plain rename (no active row at new_path) must update // the row in place and keep exactly one active row with the new // path and active status. - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let vol = VolumeId::new(); let f = sample_hashed_file(b"normal_rename", "a.jpg"); @@ -862,67 +720,62 @@ mod tests { #[test] fn upsert_location_concurrent_unique() { - // WHY: two concurrent repo handles upserting the same + // WHY: two concurrent repo handles (cloned) upserting the same // (hash, volume, path) tuple must produce exactly ONE active row. - // The `BEGIN IMMEDIATE` wrapper in upsert_location serializes the - // SELECT-then-INSERT pattern across connections; pre-fix both - // threads SELECT "not found" and both INSERT, producing two rows. - use std::sync::{Arc, Barrier}; + // Under the writer actor this is guaranteed by single-threaded + // serialization — the test still covers the observable behaviour + // contract (both return Ok; exactly one row in DB). + use std::sync::{Arc as ArcStd, Barrier}; use std::thread; - let td = tempfile::tempdir().expect("tempdir"); - let db_path = td.path().join("race.db"); - // Run migrations once up front. - { - let _ = open_and_migrate(&db_path).expect("migrate"); - } + let (_td, repo, _writer) = test_db(); + let repo = ArcStd::new(repo); let dev = device(); let vol = VolumeId::new(); // Seed the files row so both threads can link a location to it. - { - let conn = open_and_migrate(&db_path).expect("seed open"); - let seed = SqliteFileRepository::new(conn); - let f = sample_hashed_file(b"shared", "race.jpg"); - seed.upsert_file(&f, dev).expect("seed file"); - } - let f = sample_hashed_file(b"shared", "race.jpg"); - let barrier = Arc::new(Barrier::new(2)); + repo.upsert_file(&f, dev).expect("seed file"); + + let barrier = ArcStd::new(Barrier::new(2)); let mut handles = Vec::new(); for _ in 0..2 { - let db_path = db_path.clone(); - let barrier = Arc::clone(&barrier); + let repo = ArcStd::clone(&repo); + let barrier = ArcStd::clone(&barrier); let hash = f.hash; let path = f.discovered.relative_path.clone(); handles.push(thread::spawn(move || { - let conn = open_and_migrate(&db_path).expect("open"); - let repo = SqliteFileRepository::new(conn); barrier.wait(); repo.upsert_location(&hash, vol, &path, dev) .expect("upsert_location") })); } - for h in handles { - h.join().expect("thread"); - } - - // Cross-check: exactly one active row for (vol, race.jpg). - let conn = open_and_migrate(&db_path).expect("verify open"); - let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM file_locations - WHERE volume_id = ?1 AND relative_path = ?2 AND deleted_at IS NULL", - rusqlite::params![vol.0.to_string(), "race.jpg"], - |r| r.get(0), - ) - .expect("count"); - assert_eq!(count, 1, "exactly one active file_locations row"); + let results: Vec = handles + .into_iter() + .map(|h| h.join().expect("thread")) + .collect(); + + // Writer serializes: first caller Inserted, second caller sees + // the same (hash, device) row and returns Unchanged. If the + // second ever returned Updated that'd mean the app-level + // uniqueness guard skipped a check — regression we want to catch. + assert!( + results.contains(&UpsertOutcome::Inserted), + "at least one Inserted" + ); + assert!( + results.contains(&UpsertOutcome::Unchanged), + "at least one Unchanged (second caller must dedup)" + ); + // Cross-check via list: exactly one active row. + let rows = repo.list_file_locations(10, Some(vol)).expect("list"); + assert_eq!(rows.len(), 1, "exactly one active file_locations row"); + assert_eq!(rows[0].relative_path.as_str(), "race.jpg"); } #[test] fn update_location_path_nonexistent() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let vol = VolumeId::new(); diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index b514a4c..ade9520 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -2,19 +2,28 @@ #![forbid(unsafe_code)] +pub mod cmd; pub mod connection; pub mod errors; pub mod file_repo; pub mod manifest; pub mod metadata_repo; +pub mod pool; pub mod search_repo; pub mod tag_repo; pub mod volume_repo; +pub mod writer; +pub use cmd::WriteCmd; pub use connection::open_and_migrate; pub use errors::Error; pub use file_repo::SqliteFileRepository; pub use metadata_repo::SqliteMetadataRepository; +pub use pool::ReadPool; pub use search_repo::SqliteSearchRepository; pub use tag_repo::SqliteTagRepository; pub use volume_repo::SqliteVolumeRepository; +pub use writer::{SqliteWriter, SqliteWriterHandle}; + +#[cfg(any(test, feature = "test-utils"))] +pub mod test_utils; diff --git a/crates/db/src/metadata_repo.rs b/crates/db/src/metadata_repo.rs index f260db7..a2a6df9 100644 --- a/crates/db/src/metadata_repo.rs +++ b/crates/db/src/metadata_repo.rs @@ -1,35 +1,37 @@ -//! `MetadataRepository` implementation backed by rusqlite. - -use std::sync::Mutex; - +//! `MetadataRepository` adapter — writer-actor + read-pool backed. +//! +//! Post-Batch-C Task 4. The struct holds two cheap-to-clone handles: +//! a [`flume::Sender`] connected to the single writer actor +//! (spec §3.1) and a [`ReadPool`] of read-only `r2d2_sqlite` +//! connections (spec §3.4). Writes build a [`MetadataWriteCmd`] variant +//! with a `flume::bounded(1)` reply channel and block on the reply. +//! Reads run SQL directly against a pooled connection. +//! +//! No `Mutex`. The legacy `::new(conn)` constructor is +//! deleted; every caller now supplies `(writer_sender, read_pool)`. + +use flume::Sender; use perima_core::{ BlakeHash, CoreError, DeviceId, FileLocationRecord, FileSize, LocationStatus, MediaMetadata, MediaPath, MetadataRepository, UpsertOutcome, VolumeId, }; -use rusqlite::Connection; // WHY: OptionalExtension adds `.optional()` to query_row results, converting -// QueryReturnedNoRows into Ok(None) for our two-statement SELECT-then-upsert -// pattern (mirrors `SqliteFileRepository::upsert_file`). +// QueryReturnedNoRows into Ok(None) for the two-statement SELECT-then-upsert +// pattern preserved on the read path (mirrors `SqliteFileRepository`). use rusqlite::OptionalExtension; +use crate::cmd::{MetadataWriteCmd, WriteCmd}; use crate::errors::Error; +use crate::pool::ReadPool; -/// Rusqlite-backed media-metadata repository. -/// -/// WHY `Mutex`: `rusqlite::Connection` is `Send` but not -/// `Sync` (internal `RefCell` state). The [`MetadataRepository`] trait -/// requires `Send + Sync` so callers can share implementations via -/// `Arc` — e.g. the desktop `AppState` and the -/// background `MetadataQueue` worker need the same handle. Wrapping in -/// `Mutex` satisfies both bounds without `unsafe`. All DB methods lock -/// briefly; there is no blocking I/O inside the lock. +/// Writer-actor + read-pool backed media-metadata repository. /// -/// WHY `&self` throughout (unlike `SqliteFileRepository`'s `&mut self`): -/// the trait is declared with `&self` so `Arc`-sharing works without -/// `Mutex>` contortions at call sites. `FileRepository`'s -/// `&mut self` legacy is tracked for migration in v0.5.x. +/// Cheap to [`Clone`]: both fields (`flume::Sender`, `ReadPool`) are +/// internally refcounted. +#[derive(Clone)] pub struct SqliteMetadataRepository { - conn: Mutex, + writer: Sender, + reads: ReadPool, } impl std::fmt::Debug for SqliteMetadataRepository { @@ -40,19 +42,17 @@ impl std::fmt::Debug for SqliteMetadataRepository { } impl SqliteMetadataRepository { - /// Wrap an existing connection. The caller must have run - /// migrations (at least V001 + V002) before constructing this. - pub const fn new(conn: Connection) -> Self { - Self { - conn: Mutex::new(conn), - } + /// Construct an adapter from a writer-command sender + a read pool. + /// + /// WHY no migration run here: migrations happen exactly once inside + /// [`crate::SqliteWriter::start`] BEFORE the writer thread spawns + /// (spec §3.6). The read pool is opened after migrations complete. + #[must_use] + pub const fn new(writer: Sender, reads: ReadPool) -> Self { + Self { writer, reads } } } -fn now_iso() -> String { - chrono::Utc::now().to_rfc3339() -} - /// Cast a `usize` limit to `i64` for `SQLite`'s `LIMIT ?` parameter. /// /// WHY: `LIMIT` accepts a signed 64-bit integer in `SQLite`. A `usize` @@ -98,22 +98,6 @@ fn i64_to_u64_opt(v: Option) -> Result, CoreError> { .transpose() } -/// Convert `Option` to `Option` for binding as `INTEGER`. -/// -/// WHY: `rusqlite`'s `ToSql` impl does not cover `u64` (`SQLite` -/// integers are signed 64-bit). Values originating from media -/// containers (duration in ms) fit comfortably in `i64` on any -/// real-world asset; we propagate overflow as `Internal` rather than -/// truncating. -fn u64_opt_to_i64(v: Option) -> Result, CoreError> { - v.map(|raw| { - i64::try_from(raw).map_err(|_| { - CoreError::Internal(format!("duration_ms {raw} overflows SQLite INTEGER (i64)")) - }) - }) - .transpose() -} - /// Raw tuple mirroring the optional columns of a `file_metadata` row. /// /// WHY type alias: the 11-tuple is repeated in `find_by_hash` and keeps @@ -153,161 +137,35 @@ fn status_from_str(s: &str) -> Result { } impl MetadataRepository for SqliteMetadataRepository { - // WHY allow(significant_drop_tightening): the Mutex guard `conn` - // must outlive the transaction that borrows through it. Dropping - // the guard earlier would break the borrow graph — same pattern - // used throughout `file_repo.rs`. - #[allow(clippy::significant_drop_tightening)] fn upsert_metadata( &self, meta: &MediaMetadata, device: DeviceId, ) -> Result { - let mut conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; - - // WHY BEGIN IMMEDIATE: the SELECT-then-INSERT/UPDATE sequence - // must serialize across connections. Two concurrent extractor - // workers SELECTing "not found" for the same hash would both - // INSERT otherwise — SQLite's statement-level atomicity is not - // enough for a read-modify-write cycle. IMMEDIATE grabs the - // writer lock at BEGIN; the busy_timeout installed by - // `open_and_migrate` makes the second writer wait instead of - // erroring with SQLITE_BUSY. - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|e| CoreError::Internal(format!("begin immediate: {e}")))?; - - let hash_hex = meta.hash.to_hex(); - let now = now_iso(); - let dev_str = device.0.to_string(); - let duration_ms_i64 = u64_opt_to_i64(meta.duration_ms)?; - - // Mirror `SqliteFileRepository::upsert_file`'s SELECT-then- - // INSERT/UPDATE on the content-addressed PK (blake3_hash). We - // fetch the existing row's device_id + mime_type for a cheap - // equivalence proxy to classify Unchanged vs Updated. - let existing: Option<(String, Option)> = tx - .query_row( - "SELECT device_id, mime_type FROM file_metadata - WHERE blake3_hash = ?1 AND deleted_at IS NULL", - [&hash_hex], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional() - .map_err(Error::from)?; - - let outcome = match existing { - None => { - // WHY thumbnail_path / thumbnail_status NOT bound from - // `meta`: extractors always produce `None` for these - // fields. The queue worker writes them via the dedicated - // `update_thumbnail` method after thumbnail generation - // completes. A subsequent Updated upsert (triggered by - // a mime_type flip on the same hash) would otherwise - // clobber the worker's state back to NULL, silently - // losing the thumbnail association. See utof/perima#15 - // HIGH #4 for the regression this prevents. - // - // WHY `thumbnail_status` literal-default 'pending' on - // INSERT: V004 backfills the NULL rows left by v0.4.0– - // v0.4.1, and future INSERTs need to produce 'pending' - // on the same path so the - // `idx_file_metadata_thumbnail_pending` partial index - // stays populated. The literal lives in the SQL, not - // in `MediaMetadata`, because the UPDATE branch of - // this upsert deliberately never touches thumbnail - // columns (Task 2 decoupling). See utof/perima#15 - // HIGH #3. - tx.execute( - "INSERT INTO file_metadata - (blake3_hash, width, height, duration_ms, captured_at, - camera_make, camera_model, codec, bitrate_bps, mime_type, - thumbnail_status, - extracted_at, updated_at, device_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, - 'pending', - ?11, ?11, ?12)", - rusqlite::params![ - hash_hex, - meta.width, - meta.height, - duration_ms_i64, - meta.captured_at, - meta.camera_make, - meta.camera_model, - meta.codec, - meta.bitrate_bps, - meta.mime_type, - now, - dev_str, - ], - ) - .map_err(Error::from)?; - UpsertOutcome::Inserted - } - Some((existing_dev, existing_mime)) - if existing_dev == dev_str && existing_mime == meta.mime_type => - { - // WHY cheap equality proxy: comparing every Option field - // would bloat this method and still miss changes hidden - // in (say) camera_model alone. mime_type + device_id is - // the coarsest check that classifies "new extraction - // run" vs "repeat call with identical inputs". v0.4.0 - // accepts occasional false-Updated over false-Unchanged - // as the safe default. - UpsertOutcome::Unchanged - } - Some(_) => { - // WHY UPDATE omits thumbnail_path / thumbnail_status: - // same rationale as the INSERT branch above. The - // worker's `update_thumbnail` is the sole writer of - // those columns. Preserving whatever state the worker - // has already written across an Updated upsert is the - // invariant pinned by - // `upsert_metadata_preserves_thumbnail_state`. - tx.execute( - "UPDATE file_metadata - SET width = ?2, height = ?3, duration_ms = ?4, - captured_at = ?5, camera_make = ?6, camera_model = ?7, - codec = ?8, bitrate_bps = ?9, mime_type = ?10, - updated_at = ?11, device_id = ?12 - WHERE blake3_hash = ?1", - rusqlite::params![ - hash_hex, - meta.width, - meta.height, - duration_ms_i64, - meta.captured_at, - meta.camera_make, - meta.camera_model, - meta.codec, - meta.bitrate_bps, - meta.mime_type, - now, - dev_str, - ], - ) - .map_err(Error::from)?; - UpsertOutcome::Updated - } - }; - - tx.commit() - .map_err(|e| CoreError::Internal(format!("commit: {e}")))?; - Ok(outcome) + let (reply_tx, reply_rx) = flume::bounded::>(1); + // WHY clone `meta`: the command crosses a thread boundary via + // `flume::Sender::send`, which requires `'static`. `MediaMetadata` + // is `Clone`, so this is a shallow clone (the only heap payloads + // are the `Option` fields — metadata rows are small). + self.writer + .send(WriteCmd::Metadata(MetadataWriteCmd::UpsertMetadata { + record: meta.clone(), + device, + reply: reply_tx, + })) + .map_err(|e| CoreError::Internal(format!("writer send: {e}")))?; + reply_rx + .recv() + .map_err(|e| CoreError::Internal(format!("writer recv: {e}")))? } - // WHY allow(significant_drop_tightening): the Mutex guard must - // outlive the query borrow. - #[allow(clippy::significant_drop_tightening)] fn find_by_hash(&self, hash: &BlakeHash) -> Result, CoreError> { - let conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; + // WHY a pool checkout here (no writer hop): `find_by_hash` is a + // pure SELECT. Reads go directly through the `r2d2_sqlite` pool + // (spec §3.5). `PooledConnection` derefs to + // `rusqlite::Connection`, so the SQL body is lifted verbatim + // from the pre-Batch-C impl. + let conn = self.reads.get()?; let hash_hex = hash.to_hex(); let row: Option = conn .query_row( @@ -367,20 +225,13 @@ impl MetadataRepository for SqliteMetadataRepository { } } - // WHY allow(significant_drop_tightening): `stmt` + `rows` borrow - // through the Mutex guard; dropping `conn` earlier breaks the - // borrow graph (same pattern as `list_file_locations`). - #[allow(clippy::significant_drop_tightening)] #[allow(clippy::too_many_lines)] fn list_with_metadata( &self, limit: usize, volume: Option, ) -> Result)>, CoreError> { - let conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; + let conn = self.reads.get()?; let vol_filter = volume.map(|v| v.0.to_string()); // WHY single LEFT JOIN (no N+1): pairing file locations with @@ -545,24 +396,6 @@ impl MetadataRepository for SqliteMetadataRepository { Ok(out) } - /// Update the thumbnail columns on an existing `file_metadata` row. - /// - /// Separate from `upsert_metadata` so the queue worker can write - /// the thumbnail result without triggering the upsert's - /// Unchanged/Updated equivalence proxy (which compares `device_id` - /// and `mime_type` only). A thumbnail status flip pending → ready - /// must always persist. - /// - /// Wraps the UPDATE in `BEGIN IMMEDIATE` matching the v0.3.1 - /// hardening pattern; concurrent CLI + desktop writers serialize - /// via the writer lock instead of producing torn writes. - /// - /// Returns the number of rows updated (0 if no metadata row - /// exists for `hash`; 1 otherwise). - // WHY allow(significant_drop_tightening): `conn` guard must outlive - // the transaction; the suggested tightening would split the borrow - // graph across a drop boundary mid-tx. - #[allow(clippy::significant_drop_tightening)] fn update_thumbnail( &self, hash: &BlakeHash, @@ -570,46 +403,56 @@ impl MetadataRepository for SqliteMetadataRepository { status: &str, device: DeviceId, ) -> Result { - let mut conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|e| CoreError::Internal(format!("begin immediate: {e}")))?; - let hash_hex = hash.to_hex(); - let now = now_iso(); - let dev_str = device.0.to_string(); - let n = tx - .execute( - "UPDATE file_metadata - SET thumbnail_path = ?1, thumbnail_status = ?2, - updated_at = ?3, device_id = ?4 - WHERE blake3_hash = ?5 AND deleted_at IS NULL", - rusqlite::params![path, status, now, dev_str, hash_hex], - ) - .map_err(crate::errors::Error::from)?; - tx.commit() - .map_err(|e| CoreError::Internal(format!("commit: {e}")))?; - #[allow(clippy::cast_possible_truncation)] - Ok(n as u64) + let (reply_tx, reply_rx) = flume::bounded::>(1); + // WHY clone `path` / `status`: same rationale as `upsert_metadata` + // above — commands cross a thread boundary via `flume::Sender::send` + // (`'static` lifetime contract). + self.writer + .send(WriteCmd::Metadata(MetadataWriteCmd::UpdateThumbnail { + hash: *hash, + path: path.map(str::to_owned), + status: status.to_owned(), + device, + reply: reply_tx, + })) + .map_err(|e| CoreError::Internal(format!("writer send: {e}")))?; + reply_rx + .recv() + .map_err(|e| CoreError::Internal(format!("writer recv: {e}")))? } } +#[allow( + clippy::unwrap_used, + reason = "tests: unwrap is the assertion — a panic is a failing test by design" +)] #[cfg(test)] mod tests { use std::path::PathBuf; + use std::sync::Arc; - use perima_core::{DiscoveredFile, FileRepository, HashedFile}; + use perima_core::{DiscoveredFile, EventBus, FileRepository, HashedFile}; + use tempfile::TempDir; use super::*; use crate::connection::open_and_migrate; use crate::file_repo::SqliteFileRepository; + use crate::pool::ReadPool; + use crate::test_utils::NoopBus; + use crate::writer::{SqliteWriter, SqliteWriterHandle}; - fn metadata_repo() -> (tempfile::TempDir, SqliteMetadataRepository) { + /// Test harness: tempdir-backed DB, writer actor, read pool, repo. + /// + /// WHY tempfile-on-disk (not in-memory): writer + pool must share + /// the same DB file; `:memory:` is per-connection private. + fn metadata_repo() -> (TempDir, SqliteMetadataRepository, SqliteWriterHandle) { let td = tempfile::tempdir().expect("tempdir"); - let conn = open_and_migrate(&td.path().join("test.db")).expect("open"); - (td, SqliteMetadataRepository::new(conn)) + let db_path = td.path().join("test.db"); + let bus: Arc = Arc::new(NoopBus); + let writer = SqliteWriter::start(&db_path, bus).expect("writer start"); + let reads = ReadPool::open(&db_path).expect("pool open"); + let repo = SqliteMetadataRepository::new(writer.sender(), reads); + (td, repo, writer) } /// WHY duplicated from `file_repo::tests`: those helpers are @@ -656,6 +499,11 @@ mod tests { /// v0.4.2 — rows persisted before V004 carried NULL; the partial /// index `idx_file_metadata_thumbnail_pending` excluded them. /// See `utof/perima#15` HIGH #3. + /// + /// WHY direct `open_and_migrate` here (not writer+pool): this test + /// exercises the raw migration SQL, not the adapter API. Running + /// a one-shot owned connection keeps the assertion scope tight — + /// no writer actor / pool required. #[test] fn v004_backfills_null_thumbnail_status_to_pending() { let td = tempfile::tempdir().expect("tempdir"); @@ -725,7 +573,7 @@ mod tests { /// populated for future rows. See `utof/perima#15` HIGH #3. #[test] fn upsert_metadata_insert_seeds_pending_thumbnail_status() { - let (_td, repo) = metadata_repo(); + let (_td, repo, _writer) = metadata_repo(); let dev = device(); let hash = BlakeHash::from_bytes(*blake3::hash(b"seed").as_bytes()); let meta = sample_metadata(hash); @@ -746,7 +594,7 @@ mod tests { #[test] fn upsert_metadata_inserts_new() { - let (_td, repo) = metadata_repo(); + let (_td, repo, _writer) = metadata_repo(); let dev = device(); let hash = BlakeHash::from_bytes(*blake3::hash(b"payload").as_bytes()); let meta = sample_metadata(hash); @@ -756,7 +604,7 @@ mod tests { #[test] fn upsert_metadata_unchanged_on_repeat() { - let (_td, repo) = metadata_repo(); + let (_td, repo, _writer) = metadata_repo(); let dev = device(); let hash = BlakeHash::from_bytes(*blake3::hash(b"payload").as_bytes()); let meta = sample_metadata(hash); @@ -767,7 +615,7 @@ mod tests { #[test] fn upsert_metadata_updated_on_change() { - let (_td, repo) = metadata_repo(); + let (_td, repo, _writer) = metadata_repo(); let dev = device(); let hash = BlakeHash::from_bytes(*blake3::hash(b"payload").as_bytes()); let meta1 = sample_metadata(hash); @@ -781,25 +629,27 @@ mod tests { #[test] fn list_with_metadata_joins_null() { // Arrange: insert a file + location WITHOUT a metadata row. - let td = tempfile::tempdir().expect("tempdir"); + let (td, repo, writer) = metadata_repo(); let db_path = td.path().join("test.db"); let dev = device(); let vol = VolumeId::new(); let f = sample_hashed_file(b"joinsnull", "no_meta.txt"); + // WHY reuse the writer handle's sender: `writer.sender()` gives + // a cloned `flume::Sender` for the same writer thread + // that backs `repo`, so file writes are serialised through the + // same actor without opening a second writer connection. { - let conn = open_and_migrate(&db_path).expect("open"); - let file_repo = SqliteFileRepository::new(conn); + let seed_reads = ReadPool::open(&db_path).expect("seed pool"); + let file_repo = SqliteFileRepository::new(writer.sender(), seed_reads); file_repo.upsert_file(&f, dev).expect("upsert file"); file_repo .upsert_location(&f.hash, vol, &f.discovered.relative_path, dev) .expect("upsert location"); } - // Act: open a second connection and call list_with_metadata. - let meta_conn = open_and_migrate(&db_path).expect("reopen"); - let meta_repo = SqliteMetadataRepository::new(meta_conn); - let rows = meta_repo + // Act: call list_with_metadata through the pool. + let rows = repo .list_with_metadata(100, None) .expect("list_with_metadata"); @@ -817,7 +667,7 @@ mod tests { #[test] fn list_with_metadata_joins_populated() { // Arrange: insert file + location AND a metadata row for it. - let td = tempfile::tempdir().expect("tempdir"); + let (td, repo, writer) = metadata_repo(); let db_path = td.path().join("test.db"); let dev = device(); let vol = VolumeId::new(); @@ -825,25 +675,18 @@ mod tests { let meta = sample_metadata(f.hash); { - let conn = open_and_migrate(&db_path).expect("open"); - let file_repo = SqliteFileRepository::new(conn); + let seed_reads = ReadPool::open(&db_path).expect("seed pool"); + let file_repo = SqliteFileRepository::new(writer.sender(), seed_reads); file_repo.upsert_file(&f, dev).expect("upsert file"); file_repo .upsert_location(&f.hash, vol, &f.discovered.relative_path, dev) .expect("upsert location"); } - { - let conn = open_and_migrate(&db_path).expect("reopen for metadata"); - let meta_repo = SqliteMetadataRepository::new(conn); - meta_repo - .upsert_metadata(&meta, dev) - .expect("upsert metadata"); - } + // Seed metadata via the writer-backed repo. + repo.upsert_metadata(&meta, dev).expect("upsert metadata"); // Act - let meta_conn = open_and_migrate(&db_path).expect("reopen for list"); - let meta_repo = SqliteMetadataRepository::new(meta_conn); - let rows = meta_repo + let rows = repo .list_with_metadata(100, None) .expect("list_with_metadata"); @@ -872,7 +715,7 @@ mod tests { #[test] fn update_thumbnail_marks_ready_with_path() { - let (_td, repo) = metadata_repo(); + let (_td, repo, _writer) = metadata_repo(); let dev = device(); let hash = BlakeHash::from_bytes(*blake3::hash(b"ready").as_bytes()); let meta = sample_metadata(hash); @@ -895,7 +738,7 @@ mod tests { /// leave them untouched. See `utof/perima#15` HIGH #4. #[test] fn upsert_metadata_preserves_thumbnail_state() { - let (_td, repo) = metadata_repo(); + let (_td, repo, _writer) = metadata_repo(); let dev = device(); let hash = BlakeHash::from_bytes(*blake3::hash(b"preserve").as_bytes()); let mut meta = sample_metadata(hash); @@ -925,7 +768,7 @@ mod tests { #[test] fn update_thumbnail_marks_failed_keeps_path_none() { - let (_td, repo) = metadata_repo(); + let (_td, repo, _writer) = metadata_repo(); let dev = device(); let hash = BlakeHash::from_bytes(*blake3::hash(b"fail").as_bytes()); let meta = sample_metadata(hash); diff --git a/crates/db/src/pool.rs b/crates/db/src/pool.rs new file mode 100644 index 0000000..88ac886 --- /dev/null +++ b/crates/db/src/pool.rs @@ -0,0 +1,107 @@ +//! Read-only `SQLite` connection pool built on `r2d2` + `r2d2_sqlite`. +//! +//! WHY `r2d2_sqlite` over `deadpool-sqlite`: port traits in `crates/core` +//! are synchronous; `deadpool-sqlite::Object::interact` is `async` and +//! would force async through every adapter method and every `UseCase` +//! caller. `r2d2_sqlite` is the sync-fit for sync ports. Library-audit +//! §Q1 deferred the pool pick; Batch C resolves to `r2d2_sqlite 0.32`. +//! +//! The pool is built with `SQLITE_OPEN_READ_ONLY | SQLITE_OPEN_NO_MUTEX` +//! and a `with_init` hook that applies per-connection pragmas +//! (`busy_timeout`, `temp_store`, `mmap_size`, `query_only`). Migrations +//! are expected to have already run via [`crate::SqliteWriter::start`] +//! before the pool opens — spec §3.6 invariant. + +use std::path::Path; + +use perima_core::CoreError; +use r2d2::Pool; +use r2d2_sqlite::SqliteConnectionManager; +use rusqlite::OpenFlags; + +/// Read-only `SQLite` connection pool, `max_size = 4`. +/// +/// Cheap to [`Clone`]; the inner [`r2d2::Pool`] is `Arc`-backed internally. +#[derive(Clone)] +pub struct ReadPool { + inner: Pool, +} + +impl std::fmt::Debug for ReadPool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ReadPool") + .field("max_size", &self.inner.max_size()) + .finish() + } +} + +impl ReadPool { + /// Open a read-only pool against `db_path`. + /// + /// Migrations MUST have already run via [`crate::SqliteWriter::start`] + /// on the writer thread (spec §3.6 invariant) — this pool opens its + /// connections `SQLITE_OPEN_READ_ONLY` and cannot apply migrations + /// itself. + /// + /// # Errors + /// Returns [`CoreError::Internal`] on pool build failure. + pub fn open(db_path: &Path) -> Result { + let manager = SqliteConnectionManager::file(db_path) + .with_flags(OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX) + .with_init(|conn| { + conn.execute_batch( + "PRAGMA busy_timeout = 5000;\n\ + PRAGMA temp_store = MEMORY;\n\ + PRAGMA mmap_size = 268435456;\n\ + PRAGMA query_only = 1;", + ) + }); + let inner = Pool::builder() + .max_size(4) + .build(manager) + .map_err(|e| CoreError::Internal(format!("r2d2 build: {e}")))?; + Ok(Self { inner }) + } + + /// Acquire a pooled read connection. + /// + /// # Errors + /// Returns [`CoreError::Internal`] on pool checkout timeout (default + /// `connection_timeout = 30s`; see spec §9 Q5). + pub fn get(&self) -> Result, CoreError> { + self.inner + .get() + .map_err(|e| CoreError::Internal(format!("r2d2 get: {e}"))) + } + + /// Test-only helper: build an in-memory shared-cache pool for unit tests. + /// + /// `unique_name` must differ per-test so parallel runs don't collide + /// on a single shared-cache namespace. + /// + /// # Errors + /// Returns [`CoreError::Internal`] on pool build failure. + #[cfg(test)] + #[allow(dead_code)] // WHY: consumed by Task 2+ test fixtures. + pub(crate) fn in_memory_shared_cache(unique_name: &str) -> Result { + let uri = format!("file:{unique_name}?mode=memory&cache=shared"); + let manager = SqliteConnectionManager::file(&uri) + .with_flags( + OpenFlags::SQLITE_OPEN_URI + | OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .with_init(|conn| { + conn.execute_batch( + "PRAGMA busy_timeout = 5000;\n\ + PRAGMA temp_store = MEMORY;\n\ + PRAGMA query_only = 1;", + ) + }); + let inner = Pool::builder() + .max_size(4) + .build(manager) + .map_err(|e| CoreError::Internal(format!("r2d2 build: {e}")))?; + Ok(Self { inner }) + } +} diff --git a/crates/db/src/search_repo.rs b/crates/db/src/search_repo.rs index 48a2bad..9254090 100644 --- a/crates/db/src/search_repo.rs +++ b/crates/db/src/search_repo.rs @@ -1,20 +1,31 @@ //! `SearchRepository` implementation backed by rusqlite FTS5. - -use std::sync::Mutex; - +//! +//! Post-Batch-C Task 7. The struct holds two cheap-to-clone handles: +//! a [`flume::Sender`] connected to the single writer actor +//! (spec §3.1) and a [`ReadPool`] of read-only `r2d2_sqlite` +//! connections (spec §3.4). Writes build a [`SearchWriteCmd`] variant +//! with a `flume::bounded(1)` reply channel and block on the reply. +//! Reads run SQL directly against a pooled connection. +//! +//! No `Mutex`. Every caller now supplies +//! `(writer_sender, read_pool)` via `SqliteSearchRepository::new`. + +use flume::Sender; use perima_core::{CoreError, SearchHit, SearchRepository}; use rusqlite::Connection; +use crate::cmd::{SearchWriteCmd, WriteCmd}; use crate::errors::Error; +use crate::pool::ReadPool; -/// Rusqlite-backed full-text search repository. +/// Writer-actor + read-pool backed full-text search repository. /// -/// WHY `Mutex`: same rationale as `SqliteTagRepository` — -/// `Connection` is `Send` but not `Sync`; wrapping satisfies the -/// `Send + Sync` bound required by the `SearchRepository` trait without -/// `unsafe`. +/// Cheap to [`Clone`]: both fields (`flume::Sender`, `ReadPool`) are +/// internally refcounted. +#[derive(Clone)] pub struct SqliteSearchRepository { - conn: Mutex, + writer: Sender, + reads: ReadPool, } impl std::fmt::Debug for SqliteSearchRepository { @@ -25,135 +36,100 @@ impl std::fmt::Debug for SqliteSearchRepository { } impl SqliteSearchRepository { - /// Wrap an existing connection. Caller must have run migrations - /// through V007 before constructing this. - pub const fn new(conn: Connection) -> Self { - Self { - conn: Mutex::new(conn), - } + /// Construct an adapter from a writer-command sender + a read pool. + /// + /// WHY no migration run here: migrations happen exactly once inside + /// [`crate::SqliteWriter::start`] BEFORE the writer thread spawns + /// (spec §3.6). The read pool is opened after migrations complete. + #[must_use] + pub const fn new(writer: Sender, reads: ReadPool) -> Self { + Self { writer, reads } } } +// --------------------------------------------------------------------------- +// SearchRepository trait impl +// --------------------------------------------------------------------------- + impl SearchRepository for SqliteSearchRepository { - #[allow(clippy::significant_drop_tightening)] fn search(&self, query: &str, limit: u32) -> Result, CoreError> { - let conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; - - // V007: search_content has (rowid, blake3_hash, relative_path, ...), - // but SearchHit requires volume_id which lives only on file_locations. - // WHY the LEFT JOIN + representative subquery: pick the first-seen - // active file_locations row per hash to populate volume_id. The - // subquery ordering (first_seen ASC, id ASC) mirrors the trigger - // representative-selection rule, so the volume_id returned here - // agrees with the path indexed in search_content. - let mut stmt = conn - .prepare( - "SELECT sc.blake3_hash, - COALESCE(( - SELECT fl.volume_id FROM file_locations fl - WHERE fl.blake3_hash = sc.blake3_hash - AND fl.deleted_at IS NULL - ORDER BY fl.first_seen ASC, fl.id ASC - LIMIT 1 - ), ''), - sc.relative_path, - search_index.rank - FROM search_index - JOIN search_content sc ON sc.rowid = search_index.rowid - WHERE search_index MATCH ?1 - ORDER BY search_index.rank - LIMIT ?2", - ) - .map_err(Error::from)?; - - let hits = stmt - .query_map(rusqlite::params![query, limit], |row| { - Ok(SearchHit { - blake3_hash: row.get(0)?, - volume_id: row.get(1)?, - relative_path: row.get(2)?, - rank: row.get(3)?, - }) - }) - .map_err(Error::from)? - .collect::, _>>() - .map_err(Error::from)?; - - Ok(hits) + let conn = self.reads.get()?; + search_impl(&conn, query, limit) } - #[allow(clippy::significant_drop_tightening)] fn rebuild(&self) -> Result<(), CoreError> { - let mut conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; - - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(Error::from)?; - - // V007 rebuild: wipe search_content, then repopulate from joined - // live state. The search_content AFTER-INSERT/DELETE triggers keep - // search_index in sync row-by-row — no explicit FTS5 'rebuild' - // needed here. - // - // WHY not 'INSERT INTO search_index(search_index) VALUES('rebuild')': - // that primitive is an external-content resync from search_content, - // but the DELETE + INSERT path above already drives FTS via triggers. - // Calling 'rebuild' would be redundant (and defensive for a case - // that doesn't exist here: search_content-out-of-sync-with-index). - tx.execute_batch("DELETE FROM search_content;") - .map_err(Error::from)?; - - // Populate search_content: one representative location per hash, - // joined with metadata + tags. Mirrors V007 migration bulk-insert. - // WHY filename = relative_path: SQLite has no built-in REVERSE() for - // basename extraction; the unicode61 tokenizer splits on '/' and '.' - // so basenames are discoverable via token match on relative_path. - tx.execute_batch( - "INSERT INTO search_content - (blake3_hash, filename, relative_path, mime_type, camera_model, captured_at, tags) - SELECT fl.blake3_hash, - fl.relative_path, - fl.relative_path, - COALESCE(m.mime_type, ''), - COALESCE(m.camera_model, ''), - COALESCE(m.captured_at, ''), + let (tx, rx) = flume::bounded::>(1); + self.writer + .send(WriteCmd::Search(SearchWriteCmd::Rebuild { reply: tx })) + .map_err(|e| CoreError::Internal(format!("writer send: {e}")))?; + rx.recv() + .map_err(|e| CoreError::Internal(format!("writer recv: {e}")))? + } +} + +// --------------------------------------------------------------------------- +// Read-path helpers (pool variant) +// --------------------------------------------------------------------------- + +/// SELECT body for [`SearchRepository::search`]. +/// +/// V007: `search_content` has `(rowid, blake3_hash, relative_path, ...)` +/// but [`perima_core::SearchHit`] requires `volume_id` which lives only +/// on `file_locations`. Pick the first-seen active location per hash to +/// populate `volume_id`. The subquery ordering (`first_seen ASC, id ASC`) +/// mirrors the trigger representative-selection rule, so the `volume_id` +/// returned here agrees with the path indexed in `search_content`. +fn search_impl(conn: &Connection, query: &str, limit: u32) -> Result, CoreError> { + let mut stmt = conn + .prepare( + "SELECT sc.blake3_hash, COALESCE(( - SELECT GROUP_CONCAT(t.name, ' ') - FROM file_tags ft - JOIN tags t ON t.id = ft.tag_id - WHERE ft.blake3_hash = fl.blake3_hash - AND ft.deleted_at IS NULL - AND t.deleted_at IS NULL - ), '') - FROM file_locations fl - LEFT JOIN file_metadata m ON m.blake3_hash = fl.blake3_hash - AND m.deleted_at IS NULL - WHERE fl.deleted_at IS NULL - AND fl.id = ( - SELECT id FROM file_locations - WHERE blake3_hash = fl.blake3_hash AND deleted_at IS NULL - ORDER BY first_seen ASC, id ASC LIMIT 1 - );", + SELECT fl.volume_id FROM file_locations fl + WHERE fl.blake3_hash = sc.blake3_hash + AND fl.deleted_at IS NULL + ORDER BY fl.first_seen ASC, fl.id ASC + LIMIT 1 + ), ''), + sc.relative_path, + search_index.rank + FROM search_index + JOIN search_content sc ON sc.rowid = search_index.rowid + WHERE search_index MATCH ?1 + ORDER BY search_index.rank + LIMIT ?2", ) .map_err(Error::from)?; - tx.commit().map_err(Error::from)?; - Ok(()) - } + let hits = stmt + .query_map(rusqlite::params![query, limit], |row| { + Ok(SearchHit { + blake3_hash: row.get(0)?, + volume_id: row.get(1)?, + relative_path: row.get(2)?, + rank: row.get(3)?, + }) + }) + .map_err(Error::from)? + .collect::, _>>() + .map_err(Error::from)?; + + Ok(hits) } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { + use std::path::Path; + use std::sync::Arc; + use super::*; - use perima_core::{DeviceId, TagRepository}; + use perima_core::{DeviceId, EventBus, TagRepository}; + use tempfile::TempDir; + use crate::pool::ReadPool; use crate::tag_repo::SqliteTagRepository; + use crate::test_utils::NoopBus; + use crate::writer::{SqliteWriter, SqliteWriterHandle}; const DEV: &str = "dev"; const TS: &str = "2026-01-01T00:00:00Z"; @@ -165,28 +141,69 @@ mod tests { format!("{:02x}{}", n, "0".repeat(62)) } - fn test_db() -> (tempfile::TempDir, SqliteSearchRepository) { + /// Build a tempfile-on-disk DB, writer actor, read pool, and search repo. + /// + /// WHY tempfile-on-disk (not in-memory): writer + pool must share + /// the same DB file; `:memory:` is per-connection private. + /// + /// Returns `(TempDir, db_path, SqliteSearchRepository, SqliteWriterHandle)`. + /// Keep the `TempDir` alive for the test duration; the `db_path` is needed + /// by seeding helpers that open a direct raw connection. + fn test_db() -> ( + TempDir, + std::path::PathBuf, + SqliteSearchRepository, + SqliteWriterHandle, + ) { let td = tempfile::tempdir().expect("tempdir"); - let conn = crate::connection::open_and_migrate(&td.path().join("test.db")).expect("open"); - (td, SqliteSearchRepository::new(conn)) + let db_path = td.path().join("test.db"); + let bus: Arc = Arc::new(NoopBus); + let writer = SqliteWriter::start(&db_path, bus).expect("writer start"); + let reads = ReadPool::open(&db_path).expect("pool open"); + let repo = SqliteSearchRepository::new(writer.sender(), reads); + (td, db_path, repo, writer) } + /// Harness for search + tag tests. + /// + /// WHY returns `SqliteWriterHandle`: post-Batch-C Task 3, + /// `SqliteTagRepository` holds `(flume::Sender, ReadPool)`. + /// Tests must keep the writer handle alive so the writer thread + /// outlives the tag repo. fn test_db_with_tag_repo() -> ( - tempfile::TempDir, + TempDir, + std::path::PathBuf, SqliteSearchRepository, SqliteTagRepository, + SqliteWriterHandle, ) { let td = tempfile::tempdir().expect("tempdir"); - let db = td.path().join("test.db"); - let conn1 = crate::connection::open_and_migrate(&db).expect("open search"); - let conn2 = crate::connection::open_and_migrate(&db).expect("open tag"); + let db_path = td.path().join("test.db"); + + // Writer runs the migration sweep. WAL mode lets the two connections coexist. + let bus: Arc = Arc::new(NoopBus); + let writer = SqliteWriter::start(&db_path, bus).expect("writer start"); + let reads = ReadPool::open(&db_path).expect("pool open"); + ( td, - SqliteSearchRepository::new(conn1), - SqliteTagRepository::new(conn2), + db_path, + SqliteSearchRepository::new(writer.sender(), reads.clone()), + SqliteTagRepository::new(writer.sender(), reads), + writer, ) } + /// Open a direct raw connection for seeding raw SQL in tests. + /// + /// WHY raw connection: test seeding inserts rows directly (bypassing + /// the writer actor) to exercise `SQLite` triggers in isolation. The + /// writer actor is idle (blocked on `flume` channel) while tests seed, + /// so a second connection in WAL mode does not conflict. + fn seed_conn(db_path: &Path) -> Connection { + Connection::open(db_path).expect("seed conn open") + } + fn device() -> DeviceId { DeviceId::new() } @@ -234,16 +251,16 @@ mod tests { #[test] fn search_empty_index_returns_empty() { - let (_td, repo) = test_db(); + let (_td, _db, repo, _writer) = test_db(); let hits = repo.search("vacation", 50).expect("search"); assert!(hits.is_empty()); } #[test] fn search_finds_by_filename() { - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, HASH_A, VOL, "photos/sunset.jpg"); insert_metadata(&conn, HASH_A, "image/jpeg", "", ""); } @@ -255,9 +272,9 @@ mod tests { #[test] fn search_finds_by_mime_type() { - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, HASH_A, VOL, "doc.pdf"); insert_metadata(&conn, HASH_A, "application/pdf", "", ""); } @@ -269,9 +286,9 @@ mod tests { #[test] fn search_finds_by_camera_model() { - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, HASH_A, VOL, "img.jpg"); insert_metadata(&conn, HASH_A, "image/jpeg", "Canon EOS R5", ""); } @@ -282,9 +299,9 @@ mod tests { #[test] fn search_finds_by_tag() { - let (_td, repo, tag_repo) = test_db_with_tag_repo(); + let (_td, db, repo, tag_repo, _writer) = test_db_with_tag_repo(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, HASH_A, VOL, "beach.jpg"); insert_metadata(&conn, HASH_A, "image/jpeg", "", ""); } @@ -301,9 +318,9 @@ mod tests { #[test] fn rebuild_is_idempotent() { - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, HASH_A, VOL, "a.jpg"); insert_metadata(&conn, HASH_A, "image/jpeg", "", ""); } @@ -314,7 +331,7 @@ mod tests { assert!(!hits.is_empty()); // Exactly one doc (idempotent — no duplicates from double rebuild). let count: i64 = { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); conn.query_row("SELECT COUNT(*) FROM search_content", [], |r| r.get(0)) .expect("count") }; @@ -323,9 +340,9 @@ mod tests { #[test] fn trigger_sync_on_metadata_insert() { - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, HASH_A, VOL, "photos/trigger_test.jpg"); // Inserting metadata fires search_after_metadata_insert trigger. insert_metadata(&conn, HASH_A, "image/jpeg", "", ""); @@ -337,9 +354,9 @@ mod tests { #[test] fn trigger_sync_on_tag_attach() { - let (_td, repo, tag_repo) = test_db_with_tag_repo(); + let (_td, db, repo, tag_repo, _writer) = test_db_with_tag_repo(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, HASH_A, VOL, "img.jpg"); insert_metadata(&conn, HASH_A, "image/jpeg", "", ""); } @@ -355,9 +372,9 @@ mod tests { #[test] fn search_limit_is_respected() { - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); for i in 0..5u8 { let hash = format!("{:0<64}", format!("{i:x}")); insert_file(&conn, &hash, VOL, &format!("file{i}.jpg")); @@ -372,9 +389,9 @@ mod tests { #[test] fn search_no_results_for_unknown_term() { - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, HASH_A, VOL, "alpha.txt"); insert_metadata(&conn, HASH_A, "text/plain", "", ""); } @@ -395,9 +412,9 @@ mod tests { // match (SQLite convention; default `rank` returns negative BM25 // score, smaller = better). const HASH_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - let (_td, repo, tag_repo) = test_db_with_tag_repo(); + let (_td, db, repo, tag_repo, _writer) = test_db_with_tag_repo(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, HASH_A, VOL, "vacation_tagged.jpg"); insert_metadata(&conn, HASH_A, "image/jpeg", "", ""); insert_file(&conn, HASH_B, VOL, "vacation_only.jpg"); @@ -429,9 +446,9 @@ mod tests { #[test] fn filename_without_slash_is_indexed_correctly() { - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); // Root-level file: no '/' in path. insert_file(&conn, HASH_A, VOL, "rootfile.jpg"); insert_metadata(&conn, HASH_A, "image/jpeg", "", ""); @@ -490,15 +507,15 @@ mod tests { fn test_T40_metadata_update_removes_stale_tokens() { let hash_owned = hash_n(1); let HASH = hash_owned.as_str(); - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, HASH, VOL, "cam.jpg"); insert_metadata(&conn, HASH, "image/jpeg", "Canon EOS R5", ""); } // Trigger: UPDATE file_metadata fires search_after_metadata_update. { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); conn.execute( "UPDATE file_metadata SET camera_model = ?1 WHERE blake3_hash = ?2", rusqlite::params!["Nikon Zf", HASH], @@ -521,9 +538,9 @@ mod tests { fn test_T41_tag_attach_on_metadata_less_file() { let hash_owned = hash_n(2); let HASH = hash_owned.as_str(); - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, HASH, VOL, "plain.txt"); // NO metadata row attach_tag_raw(&conn, HASH, "beach"); } @@ -545,16 +562,16 @@ mod tests { fn test_T22_rename_updates_indexed_path() { let hash_owned = hash_n(3); let HASH = hash_owned.as_str(); - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, HASH, VOL, "oldname_22.jpg"); insert_metadata(&conn, HASH, "image/jpeg", "", ""); } // Rename: same hash, new path. V006 has no UPDATE trigger on // file_locations, so FTS index is not updated. { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); update_path(&conn, HASH, "oldname_22.jpg", "newname_22.jpg"); } let old_hits = repo.search("oldname_22", 50).expect("search old"); @@ -580,16 +597,16 @@ mod tests { let hash_new_owned = hash_n(5); let HASH_OLD = hash_old_owned.as_str(); let HASH_NEW = hash_new_owned.as_str(); - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, HASH_OLD, VOL, "cam.jpg"); insert_metadata(&conn, HASH_OLD, "image/jpeg", "Canon EOS R5", ""); } // Replace hash in-place (file content changed at same path). // V006 has no trigger on file_locations.blake3_hash change. { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); conn.execute( "INSERT OR IGNORE INTO files (blake3_hash, file_size, first_seen, updated_at, device_id) @@ -603,7 +620,6 @@ mod tests { ) .expect("update hash"); insert_metadata(&conn, HASH_NEW, "image/jpeg", "Nikon Zf", ""); - drop(conn); } // V006 bug: old FTS doc not retired — "Canon" still matches. let hits = repo.search("Canon", 50).expect("search"); @@ -638,11 +654,6 @@ mod tests { /// existing `files` hash. Unlike [`insert_file`] this does NOT INSERT into /// `files` — caller has already seeded that row via `insert_file` for the /// representative location. - /// - /// WHY helper: the I4 test needs two locations for the same hash on - /// different volumes; `insert_file` alone insists on `INSERT OR IGNORE` - /// into `files` which is fine, but the location insert benefits from an - /// explicit volume-aware helper. fn insert_file_at_volume(conn: &Connection, hash: &str, path: &str, volume: &str) { conn.execute( "INSERT OR IGNORE INTO files @@ -669,9 +680,7 @@ mod tests { } /// Volume-scoped rename helper: UPDATE `file_locations.relative_path` for - /// a specific `(hash, volume_id, old_path)` triple. Needed when two - /// locations share the same `relative_path` on different volumes and the - /// caller wants to rename exactly one. + /// a specific `(hash, volume_id, old_path)` triple. fn update_path_at_volume( conn: &Connection, hash: &str, @@ -700,27 +709,21 @@ mod tests { /// the rename trigger — the file remains findable via its current /// representative-path tokens across both a non-representative rename /// (no-op on FTS) and a representative rename (updates FTS). - /// - /// WHY not "secondary location's path is separately searchable": the spec - /// explicitly scopes that as out-of-scope multi-volume awareness; the - /// representative is authoritative. #[test] fn test_multi_location_rename_preserves_findability() { let hash = hash_n(10); - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); // Representative (first-seen) location on VOL. insert_file(&conn, &hash, VOL, "shared_mlr.jpg"); // Second location on VOL2, same relative_path. insert_file_at_volume(&conn, &hash, "shared_mlr.jpg", VOL2); } - // Rename the non-representative (VOL2) location. Trigger 2b is gated - // on NEW being the representative, so the rename should NOT affect - // search_content; the representative's "shared_mlr" token still matches. + // Rename the non-representative (VOL2) location. { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); update_path_at_volume(&conn, &hash, "shared_mlr.jpg", "renamed_mlr.jpg", VOL2); } assert_eq!( @@ -730,9 +733,9 @@ mod tests { ); // Rename the representative (VOL) location. Trigger 2b fires and - // updates search_content.relative_path + filename. + // updates search_content. { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); update_path_at_volume(&conn, &hash, "shared_mlr.jpg", "alpha_mlr.jpg", VOL); } assert_eq!( @@ -749,16 +752,12 @@ mod tests { /// C1: soft-deleting the representative location of a two-location file /// must re-point `search_content` to the surviving sibling, not retire the doc. - /// - /// After the delete: - /// - search("vol1") → 0 (representative was on vol1; path retired) - /// - search("vol2") → 1 (surviving sibling on vol2 is now indexed) #[test] fn test_representative_location_soft_delete_repoints() { let hash = hash_n(11); - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); // First (representative) location on VOL / vol1 path. insert_file(&conn, &hash, VOL, "vol1/repfile_c1.jpg"); // Second location on VOL2 / vol2 path — same hash. @@ -787,17 +786,15 @@ mod tests { } // Soft-delete the first (representative) location. { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); soft_delete_location(&conn, &hash, "vol1/repfile_c1.jpg"); } - // vol1 token must no longer match (representative retired from index). let vol1_hits = repo.search("vol1", 50).expect("search vol1"); assert_eq!( vol1_hits.len(), 0, "C1: search on deleted representative's path must return zero" ); - // vol2 token must still match (search_content re-pointed to sibling). let vol2_hits = repo.search("vol2", 50).expect("search vol2"); assert_eq!( vol2_hits.len(), @@ -809,39 +806,31 @@ mod tests { /// Soft-deleting the *only* location of a file must retire both the /// `search_content` row and the FTS doc. - /// - /// After the delete: - /// - `search_content` row count for this hash → 0 - /// - `search_index` query for any term → 0 results for this hash #[test] fn test_last_location_soft_delete_retires_doc() { let hash = hash_n(12); - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, &hash, VOL, "solo_retire_lsd.jpg"); insert_metadata(&conn, &hash, "image/jpeg", "RetireCamera", ""); } - // Trigger must have indexed via metadata insert; verify first. let pre = repo.search("RetireCamera", 50).expect("pre-search"); assert_eq!(pre.len(), 1, "file must be indexed before soft-delete"); - // Soft-delete the only location. { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); soft_delete_location(&conn, &hash, "solo_retire_lsd.jpg"); } - // FTS search must return empty. let hits = repo.search("RetireCamera", 50).expect("post-search"); assert!( hits.is_empty(), "last-location soft-delete must remove the file from FTS search" ); - // search_content row must be gone. let sc_count: i64 = { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); conn.query_row( "SELECT COUNT(*) FROM search_content WHERE blake3_hash = ?1", rusqlite::params![hash], @@ -857,15 +846,11 @@ mod tests { /// I5: calling `SearchRepository::rebuild()` twice produces an identical /// result set; no row-count drift in `search_content`. - /// - /// Stricter than the earlier `rebuild_is_idempotent` test: asserts the - /// exact `search_content` row count is stable across two rebuilds, not - /// just that searches still return results. #[test] fn test_rebuild_idempotence_post_v007() { - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); for i in 20u8..23u8 { let h = hash_n(i); insert_file(&conn, &h, VOL, &format!("idempotent_{i}.jpg")); @@ -874,14 +859,14 @@ mod tests { } repo.rebuild().expect("rebuild 1"); let count_after_first: i64 = { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); conn.query_row("SELECT COUNT(*) FROM search_content", [], |r| r.get(0)) .expect("count after first rebuild") }; repo.rebuild().expect("rebuild 2"); let count_after_second: i64 = { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); conn.query_row("SELECT COUNT(*) FROM search_content", [], |r| r.get(0)) .expect("count after second rebuild") }; @@ -895,7 +880,6 @@ mod tests { "I5: exactly 3 rows expected (one per file)" ); - // FTS search results must also be identical in content. let hits_first = repo .search("idempotent", 50) .expect("search after rebuild 2"); @@ -909,48 +893,38 @@ mod tests { /// I6: a single `BEGIN…COMMIT` updating `file_metadata.camera_model` + /// attaching a new tag + renaming `file_locations.relative_path` must /// produce FTS docs that reflect ALL three changes after commit. - /// - /// WHY fire-order independence: the three business triggers (2b, 3b, 4a) - /// all update `search_content` from joined live state, so regardless of - /// `SQLite`'s trigger-fire order the final `search_content` row converges. #[test] fn test_combined_transaction_update() { let hash = hash_n(13); - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, &hash, VOL, "combined_old_ctx.jpg"); insert_metadata(&conn, &hash, "image/jpeg", "OldCamera", ""); } - // Pre-condition: old tokens indexed. let pre = repo.search("OldCamera", 50).expect("pre OldCamera"); assert_eq!(pre.len(), 1, "pre-condition: OldCamera must be indexed"); - // Execute all three mutations in one transaction. { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); conn.execute_batch("BEGIN;").expect("begin"); - // 1. Rename the location (trigger 2b if it is the representative). conn.execute( "UPDATE file_locations SET relative_path = 'combined_new_ctx.jpg' WHERE blake3_hash = ?1 AND relative_path = 'combined_old_ctx.jpg'", rusqlite::params![hash], ) .expect("rename"); - // 2. Update camera_model (trigger 3b). conn.execute( "UPDATE file_metadata SET camera_model = 'NewCamera' WHERE blake3_hash = ?1", rusqlite::params![hash], ) .expect("update metadata"); - // 3. Attach a new tag (trigger 4a). attach_tag_raw(&conn, &hash, "combined_tag_ctx"); conn.execute_batch("COMMIT;").expect("commit"); } - // FTS must reflect NEW camera_model. let new_cam = repo.search("NewCamera", 50).expect("NewCamera"); assert_eq!( new_cam.len(), @@ -958,18 +932,15 @@ mod tests { "I6: NewCamera must be indexed post-commit" ); - // FTS must no longer contain OLD camera_model. let old_cam = repo.search("OldCamera", 50).expect("OldCamera after"); assert!( old_cam.is_empty(), "I6: OldCamera must not appear after metadata update in combined tx" ); - // FTS must reflect the new tag. let tag_hits = repo.search("combined_tag_ctx", 50).expect("tag"); assert_eq!(tag_hits.len(), 1, "I6: new tag must be indexed post-commit"); - // FTS must reflect the new path token. let new_path = repo.search("combined_new_ctx", 50).expect("new path"); assert_eq!( new_path.len(), @@ -977,7 +948,6 @@ mod tests { "I6: new relative_path token must be indexed post-commit" ); - // FTS must no longer match the old path token. let old_path = repo.search("combined_old_ctx", 50).expect("old path"); assert!( old_path.is_empty(), @@ -1018,21 +988,17 @@ mod tests { } /// T43 (#1): soft-deleting a tag must remove its tokens from FTS. - /// Bug in V007: no trigger on `tags.deleted_at`; aggregation queries - /// filter `ft.deleted_at IS NULL` but never `t.deleted_at IS NULL`, - /// so tokens of a soft-deleted tag remain indexed forever. #[test] #[allow(non_snake_case)] fn test_T43_tag_soft_delete_removes_tokens_from_fts() { let hash_owned = hash_n(43); let hash_s = hash_owned.as_str(); - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, hash_s, VOL, "cabin_43.jpg"); attach_tag_raw(&conn, hash_s, "vacation_43"); } - // Pre-condition: tag indexed. assert_eq!( search_count(&repo, "vacation_43"), 1, @@ -1040,18 +1006,16 @@ mod tests { ); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); soft_delete_tag_raw(&conn, "vacation_43"); } - // V007 bug: token still matches after soft-delete. assert_eq!( search_count(&repo, "vacation_43"), 0, "#1: soft-deleted tag token must NOT match (V007 bug: no tag-soft-delete trigger)" ); - // rebuild() must also drop the leaked token (aggregation filter fix). repo.rebuild().expect("rebuild"); assert_eq!( search_count(&repo, "vacation_43"), @@ -1063,10 +1027,6 @@ mod tests { /// T44 (#2): `search_after_location_hash_change` must NOT overwrite an /// existing representative's indexed path with NEW.* when NEW is not /// the first-seen active location for its target hash. - /// - /// Bug in V007 trigger 2a (lines 186-200): the UPDATE unconditionally sets - /// `filename = NEW.relative_path`, violating the "`first_seen` ASC, id ASC - /// representative" rule codex found at lines 94-97 of V007. #[test] #[allow(non_snake_case)] fn test_T44_hash_change_preserves_representative_path() { @@ -1074,25 +1034,20 @@ mod tests { let hash_b = hash_n(45); let a_s = hash_a.as_str(); let b_s = hash_b.as_str(); - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); - // HASH_A's representative location: earlier_44.jpg (first_seen=TS). + let conn = seed_conn(&db); insert_file(&conn, a_s, VOL, "earlier_44.jpg"); - // HASH_B's only location: later_44.jpg (first_seen=TS+1 via second row). insert_file(&conn, b_s, VOL, "later_44.jpg"); } - // Pre-condition: searching "earlier_44" hits HASH_A's doc. assert_eq!( search_count(&repo, "earlier_44"), 1, "pre: representative path for HASH_A must match" ); - // Change later_44.jpg's hash from HASH_B to HASH_A (file content changed - // at that path to match HASH_A). Trigger 2a fires for the hash change. { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); conn.execute( "UPDATE file_locations SET blake3_hash = ?1 WHERE blake3_hash = ?2 AND relative_path = 'later_44.jpg'", @@ -1101,8 +1056,6 @@ mod tests { .expect("hash change"); } - // V007 bug: trigger 2a's UPDATE overwrote search_content(HASH_A) with - // later_44.jpg, clobbering the representative path earlier_44.jpg. assert_eq!( search_count(&repo, "earlier_44"), 1, @@ -1112,9 +1065,6 @@ mod tests { /// T45a (#3a): combined UPDATE of `blake3_hash` + `deleted_at` must NOT seed /// a `search_content` row for the NEW (tombstoned) hash. - /// - /// Bug in V007 trigger 2a: no `NEW.deleted_at IS NULL` guard — fires even - /// when the updated row is simultaneously tombstoned. #[test] #[allow(non_snake_case)] fn test_T45a_soft_delete_with_hash_change_skips_fts_insert() { @@ -1122,9 +1072,9 @@ mod tests { let hash_new = hash_n(47); let old_s = hash_old.as_str(); let new_s = hash_new.as_str(); - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, old_s, VOL, "combined_45.jpg"); conn.execute( "INSERT OR IGNORE INTO files @@ -1135,9 +1085,8 @@ mod tests { .expect("insert new files row"); } - // Combined: blake3_hash change + deleted_at set in one UPDATE. { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); conn.execute( "UPDATE file_locations SET blake3_hash = ?1, deleted_at = ?2 WHERE blake3_hash = ?3 AND relative_path = 'combined_45.jpg'", @@ -1146,10 +1095,11 @@ mod tests { .expect("hash change + soft-delete"); } - // Verify no search_content row exists for hash_new (tombstoned - // simultaneously — trigger 2a must not insert). + // Avoid unused variable warning — the repo must stay alive to keep the writer sender alive. + let _ = &repo; + let sc_count_new: i64 = { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); conn.query_row( "SELECT COUNT(*) FROM search_content WHERE blake3_hash = ?1", rusqlite::params![new_s], @@ -1165,27 +1115,20 @@ mod tests { /// T45b (#3b): restoring a soft-deleted sole-location row must recreate /// the FTS doc. - /// - /// Bug in V007: trigger 2c handles soft-delete (`OLD.deleted_at IS NULL - /// AND NEW.deleted_at IS NOT NULL`) but there is no inverse trigger for - /// restore. After restore the row's representative-selection value is - /// back in play but `search_content` is still empty. #[test] #[allow(non_snake_case)] fn test_T45b_location_restore_recreates_fts_doc() { let hash = hash_n(48); let hash_s = hash.as_str(); - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, hash_s, VOL, "restore_45_token.jpg"); } - // Pre: indexed. assert_eq!(search_count(&repo, "restore_45_token"), 1, "pre: indexed"); - // Soft-delete the only location. { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); soft_delete_location(&conn, hash_s, "restore_45_token.jpg"); } assert_eq!( @@ -1194,13 +1137,11 @@ mod tests { "after soft-delete: retired" ); - // Restore by clearing deleted_at. { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); restore_location(&conn, hash_s, "restore_45_token.jpg"); } - // V007 bug: no restore trigger → FTS doc never recreated. assert_eq!( search_count(&repo, "restore_45_token"), 1, @@ -1210,19 +1151,14 @@ mod tests { /// T46 (#4): soft-deleting a `file_metadata` row must clear its tokens /// from FTS. - /// - /// Bug in V007 trigger 3b (lines 269-276): blindly copies `NEW.mime_type` - /// etc. with no `deleted_at` filter, and there is no dedicated soft-delete - /// trigger — so MIME/camera/capture tokens of a tombstoned metadata row - /// stay indexed. #[test] #[allow(non_snake_case)] fn test_T46_metadata_soft_delete_clears_tokens() { let hash = hash_n(49); let hash_s = hash.as_str(); - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, hash_s, VOL, "meta_soft_46.jpg"); insert_metadata(&conn, hash_s, "image/jpeg", "CanonGone46", ""); } @@ -1233,11 +1169,10 @@ mod tests { ); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); soft_delete_metadata(&conn, hash_s); } - // V007 bug: tokens remain after metadata soft-delete. assert_eq!( search_count(&repo, "CanonGone46"), 0, @@ -1246,23 +1181,16 @@ mod tests { } /// T47 (reviewer #2): `search_after_metadata_insert` must not seed FTS - /// tokens when the metadata row is already tombstoned (e.g. CRDT merge - /// of a row a peer already deleted). - /// - /// V007 bug: trigger at V007:252-266 has no `NEW.deleted_at IS NULL` - /// guard; blindly copies `NEW.mime_type` / `camera_model` / `captured_at` - /// into `search_content` even when NEW arrives soft-deleted. + /// tokens when the metadata row is already tombstoned. #[test] #[allow(non_snake_case)] fn test_T47_tombstoned_metadata_insert_skipped() { let hash = hash_n(50); let hash_s = hash.as_str(); - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file(&conn, hash_s, VOL, "ghost_47.jpg"); - // Directly INSERT metadata with deleted_at already set — simulates - // a CRDT merge from a peer that already soft-deleted the row. conn.execute( "INSERT INTO file_metadata (blake3_hash, mime_type, camera_model, captured_at, @@ -1273,7 +1201,6 @@ mod tests { .expect("insert tombstoned metadata"); } - // V007 bug: tokens indexed even though metadata arrived tombstoned. assert_eq!( search_count(&repo, "GhostCam47"), 0, @@ -1284,50 +1211,35 @@ mod tests { /// T48 (reviewer #3): `search_after_file_locations_insert` must aggregate /// tags + metadata with `deleted_at IS NULL` filters on BOTH the link /// table AND the joined entity. - /// - /// V007 bug: trigger at V007:132-151 filters `ft.deleted_at IS NULL` but - /// not `t.deleted_at IS NULL`, and LEFT JOINs `file_metadata` without - /// `m.deleted_at IS NULL`. A new location inserted after a - /// `search_content` row retirement re-seeds the doc with tokens from - /// tombstoned tags/metadata. #[test] #[allow(non_snake_case)] fn test_T48_fresh_location_seed_excludes_soft_deleted_tag_and_metadata() { let hash = hash_n(51); let hash_s = hash.as_str(); - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); { - let conn = repo.conn.lock().expect("lock"); - // Seed: one location + attached tag + metadata — all live. + let conn = seed_conn(&db); insert_file(&conn, hash_s, VOL, "seed_48.jpg"); attach_tag_raw(&conn, hash_s, "ghostag_48"); insert_metadata(&conn, hash_s, "image/jpeg", "GhostCam48", ""); - // Soft-delete the tag AND the metadata (tombstones only, not - // cascaded — file_tags rows remain with their original deleted_at). soft_delete_tag_raw(&conn, "ghostag_48"); soft_delete_metadata(&conn, hash_s); - // Retire the search_content row by soft-deleting the sole location. soft_delete_location(&conn, hash_s, "seed_48.jpg"); } - // Confirm FTS retired. assert_eq!( search_count(&repo, "ghostag_48"), 0, "pre: tag token must be absent after soft-delete + retire" ); - // Now insert a fresh location for the same hash on VOL2 — - // fires search_after_file_locations_insert which re-seeds - // search_content(hash) from the aggregations. { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); insert_file_at_volume(&conn, hash_s, "reseed_48.jpg", VOL2); } - // V007 bug: seed uses t/m-unfiltered JOINs, resurrecting tombstoned tokens. assert_eq!( search_count(&repo, "ghostag_48"), 0, @@ -1343,12 +1255,6 @@ mod tests { // ── Task 5: proptest — FTS5 invariant across tag churn ────────────────── /// Soft-delete a `file_tags` row by setting `deleted_at` (tag detach). - /// - /// WHY raw SQL: proptest body has only a single `Connection` from the - /// `SqliteSearchRepository`'s mutex; using `SqliteTagRepository` would - /// require a second open connection and a `TempDir` with WAL on, which - /// adds noise without adding coverage. The trigger fires on the SQL UPDATE - /// regardless of which layer issues it. fn detach_tag_raw(conn: &Connection, hash: &str, tag_name: &str) { conn.execute( "UPDATE file_tags SET deleted_at = ?1, updated_at = ?1, device_id = ?2 @@ -1361,24 +1267,19 @@ mod tests { } /// Trigger 5: renaming a tag must update every `search_content` row that - /// references it. After `UPDATE tags SET name = 'holiday'` for the tag - /// previously named "vacation": - /// - search("vacation") → 0 - /// - search("holiday") → 3 (all files that had the tag attached) + /// references it. #[test] fn test_tag_name_rename_propagates() { - let (_td, repo) = test_db(); + let (_td, db, repo, _writer) = test_db(); let hashes: Vec = (30u8..33u8).map(hash_n).collect(); { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); for (i, h) in hashes.iter().enumerate() { insert_file(&conn, h, VOL, &format!("trnp_{i}.jpg")); - // Attach tag "vacation" to all three files. attach_tag_raw(&conn, h, "vacation"); } } - // Pre-condition: all 3 files discoverable under "vacation". let pre = repo.search("vacation", 50).expect("pre-vacation"); assert_eq!( pre.len(), @@ -1386,9 +1287,8 @@ mod tests { "pre-condition: all 3 files must be indexed under 'vacation'" ); - // Rename the tag — trigger 5 must update all three search_content rows. { - let conn = repo.conn.lock().expect("lock"); + let conn = seed_conn(&db); conn.execute( "UPDATE tags SET name = 'holiday' WHERE name = 'vacation'", [], @@ -1396,7 +1296,6 @@ mod tests { .expect("rename tag"); } - // Old name must yield zero results. let old_hits = repo.search("vacation", 50).expect("vacation after rename"); assert_eq!( old_hits.len(), @@ -1404,7 +1303,6 @@ mod tests { "trigger 5: 'vacation' must return zero after tag rename" ); - // New name must match all three files. let new_hits = repo.search("holiday", 50).expect("holiday"); assert_eq!( new_hits.len(), @@ -1422,10 +1320,7 @@ mod tests { Detach(usize, usize), } - /// Small universe: 3 files × 3 tags — large enough to generate - /// interesting interactions, small enough that the invariant check - /// (O(files × tags) FTS queries) completes inside the default proptest - /// timeout. + /// Small universe: 3 files × 3 tags. const PROP_FILES: &[&str] = &[ "7100000000000000000000000000000000000000000000000000000000000000", "7200000000000000000000000000000000000000000000000000000000000000", @@ -1435,13 +1330,25 @@ mod tests { const PROP_VOL: &str = "00000000-0000-0000-0000-000000000099"; proptest::proptest! { + // WHY cases=64 (down from the 256 default): post-Batch-C each proptest + // case creates a writer-actor thread + `r2d2` read pool + a single + // `seed_conn` on a fresh tempdir DB — ~5x the per-case cost of the + // pre-Task-7 single-`Mutex` fixture (#124). The seed + // connection is already hoisted below to case scope so per-op + // `Connection::open` churn is gone; the residual per-case cost is + // the writer-thread + pool init itself. At 256 cases the cumulative + // overhead exceeds the 80s terminate-after window on VM filesystems + // even though no individual case contends for the write lock. 64 + // cases × up to 30 ops = ~1 920 ops per proptest, still strong + // combinatorial coverage for FTS trigger invariants. + #![proptest_config(proptest::test_runner::Config { + cases: 64, + ..proptest::test_runner::Config::default() + })] + /// **Invariant:** after every Attach / Detach operation, for every /// `(file, tag)` pair, `MATCH tag_name` returns the file iff /// `file_tags.deleted_at IS NULL` for that pair. - /// - /// WHY random sequences: fixed-input tests (Task 4) cover specific - /// triggers; randomised churn covers interaction effects — e.g. - /// double-attach, detach-never-attached, attach-after-detach-after-attach. #[test] fn fts_consistent_under_tag_churn( ops in proptest::collection::vec( @@ -1459,52 +1366,44 @@ mod tests { ), ) { // Fresh DB per proptest case — each case is independent. - let (_td, repo) = test_db(); - - // Seed all three files (no metadata needed; trigger 4a covers - // metadata-less files, which T41 already pins as a fixed test). - { - let conn = repo.conn.lock().expect("lock"); - for (i, hash) in PROP_FILES.iter().enumerate() { - insert_file( - &conn, - hash, - PROP_VOL, - &format!("prop_file_{i}.jpg"), - ); - } + let (_td, db, repo, _writer) = test_db(); + + // WHY single seed_conn hoisted to case scope: each `Connection::open` + // on a WAL file does several syscalls (open, SHARED lock, -shm/-wal + // handshake, header read). With 30 ops × 256 default cases × 2 + // proptests = ~15k opens, that cost compounded to >80s on VM + // filesystems (#124). Reusing one connection per case keeps all + // writes as auto-commit statements — no transaction state crosses + // ops, so test semantics are identical to the per-op-scope version. + let conn = seed_conn(&db); + + // Seed all three files. + for (i, hash) in PROP_FILES.iter().enumerate() { + insert_file( + &conn, + hash, + PROP_VOL, + &format!("prop_file_{i}.jpg"), + ); } - // Shadow state: (file_idx, tag_idx) → currently attached? let mut attached: std::collections::HashMap<(usize, usize), bool> = std::collections::HashMap::new(); for op in &ops { - { - let conn = repo.conn.lock().expect("lock"); - match *op { - TagOp::Attach(f, t) => { - // attach_tag_raw is idempotent (INSERT OR IGNORE + - // existing file_tags rows with deleted_at non-NULL - // are ignored); re-attaching after a detach creates - // a fresh row, so we unconditionally set attached=true. - attach_tag_raw(&conn, PROP_FILES[f], PROP_TAGS[t]); - attached.insert((f, t), true); - } - TagOp::Detach(f, t) => { - // Only detach when the shadow state says the pair is - // active; otherwise the UPDATE is a harmless no-op - // and the shadow stays false. - if *attached.get(&(f, t)).unwrap_or(&false) { - detach_tag_raw(&conn, PROP_FILES[f], PROP_TAGS[t]); - attached.insert((f, t), false); - } + match *op { + TagOp::Attach(f, t) => { + attach_tag_raw(&conn, PROP_FILES[f], PROP_TAGS[t]); + attached.insert((f, t), true); + } + TagOp::Detach(f, t) => { + if *attached.get(&(f, t)).unwrap_or(&false) { + detach_tag_raw(&conn, PROP_FILES[f], PROP_TAGS[t]); + attached.insert((f, t), false); } } - } // mutex guard dropped before the invariant queries below. + } - // Invariant check: for every (file, tag) pair the FTS result - // must agree with the shadow state. for (f_idx, &file_hash) in PROP_FILES.iter().enumerate() { for (t_idx, &tag_name) in PROP_TAGS.iter().enumerate() { let is_attached = @@ -1532,14 +1431,6 @@ mod tests { } // ── v0.6.4 proptest — ground-truth invariant over full soft-delete op universe ── - // - // Codex finding #9 observed that the tag-churn proptest above could not - // catch #1-#4 because its op universe is narrow and its invariant reads - // only `file_tags.deleted_at`. This sibling proptest extends coverage to - // EVERY mutable surface that affects search_content + checks an - // INDEPENDENT ground-truth (not the `rebuild()` SQL — a different shape - // assembled per-field in this module, so a future bug re-entering both - // trigger and rebuild paths is still caught here). #[derive(Debug, Clone)] enum SoftOp { @@ -1554,7 +1445,7 @@ mod tests { RestoreLocation(usize), } - /// Restore a soft-deleted tag (mirrors what a CRDT merge or operator fix would do). + /// Restore a soft-deleted tag. fn restore_tag_raw(conn: &Connection, tag_name: &str) { conn.execute( "UPDATE tags SET deleted_at = NULL, updated_at = ?1 @@ -1575,8 +1466,7 @@ mod tests { } /// Insert or replace a metadata row for `hash` with a deterministic camera - /// token derived from `variant`. Used to exercise metadata-update triggers - /// with distinguishable tokens across rounds. + /// token derived from `variant`. fn set_metadata_variant(conn: &Connection, hash: &str, variant: u8) { let cam = format!("cam_{variant}"); let mime = format!("image/type{variant}"); @@ -1595,8 +1485,7 @@ mod tests { .expect("set_metadata_variant"); } - /// A single expected `search_content` row computed from joined live state, - /// using per-field subqueries independent of `rebuild()`'s SQL shape. + /// A single expected `search_content` row computed from joined live state. #[derive(Debug, PartialEq, Eq, Hash, Clone)] struct GroundTruthRow { blake3_hash: String, @@ -1608,9 +1497,6 @@ mod tests { } /// Compute expected `search_content` from joined live state. - /// Per-hash: one row iff an active `file_location` exists. Fields populated - /// from first-seen active location (path) + active metadata (mime/camera/ - /// capture) + `GROUP_CONCAT` of active (`file_tags` × tags) names (tags). fn compute_ground_truth(conn: &Connection) -> Vec { let mut stmt = conn .prepare( @@ -1648,8 +1534,6 @@ mod tests { |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .unwrap_or_else(|_| (String::new(), String::new(), String::new())); - // GROUP_CONCAT isn't deterministic across SQLite builds; compute - // in Rust with sort-by-name for stable comparison. let mut tag_names: Vec = { let mut s = conn .prepare( @@ -1678,8 +1562,7 @@ mod tests { out } - /// Read actual `search_content` into the same shape, with `tags` - /// tokens sorted so the comparison is order-insensitive. + /// Read actual `search_content` into the same shape. fn read_search_content(conn: &Connection) -> Vec { let mut stmt = conn .prepare( @@ -1714,14 +1597,20 @@ mod tests { const SOFT_VOL: &str = "00000000-0000-0000-0000-0000000000aa"; proptest::proptest! { + // See `fts_consistent_under_tag_churn` for the cases-reduction + // rationale (#124). This proptest is set to cases=32 (half the other + // one) because each op runs `compute_ground_truth` — up to 8 + // per-hash SELECTs plus a `read_search_content` scan — on top of the + // mutation. With 25 ops × 9 queries ≈ 225 DB ops per case, the + // per-case cost is ~2x the tag-churn proptest. + #![proptest_config(proptest::test_runner::Config { + cases: 32, + ..proptest::test_runner::Config::default() + })] + /// **Invariant:** after EVERY op, search_content rows (incrementally /// maintained by triggers) must equal the ground-truth rows computed /// directly from joined live state via independent per-field subqueries. - /// - /// WHY independent ground truth: if a future migration reintroduces a - /// shared bug into both `rebuild()` and the triggers, an invariant that - /// compares trigger-path ⟷ rebuild-path would pass; per-field subqueries - /// in this test are a second source. #[test] fn fts_matches_ground_truth_under_soft_delete_churn( ops in proptest::collection::vec( @@ -1766,60 +1655,59 @@ mod tests { 0..25, ), ) { - let (_td, repo) = test_db(); - { - let conn = repo.conn.lock().expect("lock seed"); - for (i, h) in SOFT_FILES.iter().enumerate() { - insert_file(&conn, h, SOFT_VOL, &format!("soft_{i}.jpg")); - } + let (_td, db, _repo, _writer) = test_db(); + + // See `fts_consistent_under_tag_churn` for the rationale — one + // seed_conn per case instead of per-op avoids ~15k extra + // `Connection::open` calls on the WAL-mode DB file (#124). + let conn = seed_conn(&db); + for (i, h) in SOFT_FILES.iter().enumerate() { + insert_file(&conn, h, SOFT_VOL, &format!("soft_{i}.jpg")); } for op in &ops { - { - let conn = repo.conn.lock().expect("lock op"); - match *op { - SoftOp::AttachTag(f, t) => { - attach_tag_raw(&conn, SOFT_FILES[f], SOFT_TAGS[t]); - } - SoftOp::DetachTag(f, t) => { - detach_tag_raw(&conn, SOFT_FILES[f], SOFT_TAGS[t]); - } - SoftOp::SoftDeleteTag(t) => { - soft_delete_tag_raw(&conn, SOFT_TAGS[t]); - } - SoftOp::RestoreTag(t) => { - restore_tag_raw(&conn, SOFT_TAGS[t]); - } - SoftOp::SetMetadata(f, v) => { - set_metadata_variant(&conn, SOFT_FILES[f], v); - } - SoftOp::SoftDeleteMetadata(f) => { - soft_delete_metadata(&conn, SOFT_FILES[f]); - } - SoftOp::RestoreMetadata(f) => { - restore_metadata_raw(&conn, SOFT_FILES[f]); - } - SoftOp::SoftDeleteLocation(f) => { - soft_delete_location( - &conn, - SOFT_FILES[f], - &format!("soft_{f}.jpg"), - ); - } - SoftOp::RestoreLocation(f) => { - restore_location( - &conn, - SOFT_FILES[f], - &format!("soft_{f}.jpg"), - ); - } + match *op { + SoftOp::AttachTag(f, t) => { + attach_tag_raw(&conn, SOFT_FILES[f], SOFT_TAGS[t]); + } + SoftOp::DetachTag(f, t) => { + detach_tag_raw(&conn, SOFT_FILES[f], SOFT_TAGS[t]); + } + SoftOp::SoftDeleteTag(t) => { + soft_delete_tag_raw(&conn, SOFT_TAGS[t]); + } + SoftOp::RestoreTag(t) => { + restore_tag_raw(&conn, SOFT_TAGS[t]); + } + SoftOp::SetMetadata(f, v) => { + set_metadata_variant(&conn, SOFT_FILES[f], v); + } + SoftOp::SoftDeleteMetadata(f) => { + soft_delete_metadata(&conn, SOFT_FILES[f]); + } + SoftOp::RestoreMetadata(f) => { + restore_metadata_raw(&conn, SOFT_FILES[f]); + } + SoftOp::SoftDeleteLocation(f) => { + soft_delete_location( + &conn, + SOFT_FILES[f], + &format!("soft_{f}.jpg"), + ); + } + SoftOp::RestoreLocation(f) => { + restore_location( + &conn, + SOFT_FILES[f], + &format!("soft_{f}.jpg"), + ); } } - let (actual, expected) = { - let conn = repo.conn.lock().expect("lock invariant"); - (read_search_content(&conn), compute_ground_truth(&conn)) - }; + let (actual, expected) = ( + read_search_content(&conn), + compute_ground_truth(&conn), + ); proptest::prop_assert_eq!( actual, expected, diff --git a/crates/db/src/tag_repo.rs b/crates/db/src/tag_repo.rs index 408cf3a..13ed42f 100644 --- a/crates/db/src/tag_repo.rs +++ b/crates/db/src/tag_repo.rs @@ -1,26 +1,33 @@ -//! `TagRepository` implementation backed by rusqlite. +//! `TagRepository` adapter — writer-actor + read-pool backed. +//! +//! Post-Batch-C Task 3. The struct holds two cheap-to-clone handles: +//! a [`flume::Sender`] connected to the single writer actor +//! (spec §3.1) and a [`ReadPool`] of read-only `r2d2_sqlite` +//! connections (spec §3.4). Writes build a [`TagWriteCmd`] variant with +//! a `flume::bounded(1)` reply channel and block on the reply. Reads +//! run SQL directly against a pooled connection. +//! +//! No `Mutex`. The legacy `::new(conn)` constructor is +//! deleted; every caller now supplies `(writer_sender, read_pool)`. use std::collections::HashMap; -use std::sync::Mutex; +use flume::Sender; use perima_core::{BlakeHash, CoreError, DeviceId, Tag, TagRepository, normalize_tag}; -use rusqlite::Connection; -// WHY: OptionalExtension adds `.optional()` to query_row results, converting -// QueryReturnedNoRows into Ok(None) for our SELECT-then-INSERT upsert pattern. -use rusqlite::OptionalExtension; use uuid::Uuid; +use crate::cmd::{TagWriteCmd, WriteCmd}; use crate::errors::Error; +use crate::pool::ReadPool; -/// Rusqlite-backed tag repository. +/// Writer-actor + read-pool backed tag + file-tag repository. /// -/// WHY `Mutex`: `rusqlite::Connection` is `Send` but not -/// `Sync` (internal `RefCell` state). The [`TagRepository`] trait -/// requires `Send + Sync` for `Arc` sharing in desktop state. -/// Wrapping in `Mutex` satisfies both bounds without `unsafe`. All DB -/// methods lock briefly; there is no blocking I/O inside the lock. +/// Cheap to [`Clone`]: both fields (`flume::Sender`, `ReadPool`) are +/// internally refcounted. +#[derive(Clone)] pub struct SqliteTagRepository { - conn: Mutex, + writer: Sender, + reads: ReadPool, } impl std::fmt::Debug for SqliteTagRepository { @@ -31,202 +38,99 @@ impl std::fmt::Debug for SqliteTagRepository { } impl SqliteTagRepository { - /// Wrap an existing connection. Caller must have run migrations - /// (at least through V005) before constructing this. - pub const fn new(conn: Connection) -> Self { - Self { - conn: Mutex::new(conn), - } + /// Construct an adapter from a writer-command sender + a read pool. + /// + /// WHY no migration run here: migrations happen exactly once inside + /// [`crate::SqliteWriter::start`] BEFORE the writer thread spawns + /// (spec §3.6). The read pool is opened after migrations complete. + #[must_use] + pub const fn new(writer: Sender, reads: ReadPool) -> Self { + Self { writer, reads } } } -fn now_iso() -> String { - chrono::Utc::now().to_rfc3339() -} - impl TagRepository for SqliteTagRepository { - // WHY allow(significant_drop_tightening): the Mutex guard `conn` - // must outlive the transaction that borrows through it. Dropping - // the guard earlier would break the borrow graph — same pattern - // used throughout `metadata_repo.rs`. - #[allow(clippy::significant_drop_tightening)] fn upsert_tag(&self, name: &str, device: DeviceId) -> Result { + // WHY normalize adapter-side (not in the writer): `normalize_tag` + // returns `CoreError::InvalidTag` on empty/whitespace/overlong + // names. Surfacing that error before the writer hop keeps the + // error path synchronous + matches the pre-Batch-C behaviour + // (where the SELECT-then-INSERT block also normalized first). let normalized = normalize_tag(name)?; - let mut conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; - - // WHY BEGIN IMMEDIATE: the SELECT-then-INSERT sequence must be - // atomic across connections. Two concurrent upserts for the same - // name would both see "not found" and both INSERT, producing - // duplicate active tags. IMMEDIATE grabs the writer lock at BEGIN. - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|e| CoreError::Internal(format!("begin immediate: {e}")))?; - - let existing: Option<(String, String)> = tx - .query_row( - "SELECT id, first_seen FROM tags WHERE name = ?1 AND deleted_at IS NULL", - [&normalized], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional() - .map_err(Error::from)?; - - let tag = if let Some((id_str, first_seen)) = existing { - let id = Uuid::parse_str(&id_str) - .map_err(|e| CoreError::Internal(format!("invalid uuid in db: {e}")))?; - Tag { - id, + let (reply_tx, reply_rx) = flume::bounded::>(1); + self.writer + .send(WriteCmd::Tag(TagWriteCmd::UpsertTag { name: normalized, - first_seen, - } - } else { - let id = Uuid::now_v7(); - let now = now_iso(); - let dev_str = device.0.to_string(); - tx.execute( - "INSERT INTO tags (id, name, first_seen, updated_at, device_id) - VALUES (?1, ?2, ?3, ?3, ?4)", - rusqlite::params![id.to_string(), normalized, now, dev_str], - ) - .map_err(Error::from)?; - Tag { - id, - name: normalized, - first_seen: now, - } - }; - - tx.commit() - .map_err(|e| CoreError::Internal(format!("commit: {e}")))?; - - Ok(tag) + device, + reply: reply_tx, + })) + .map_err(|e| CoreError::Internal(format!("writer send: {e}")))?; + reply_rx + .recv() + .map_err(|e| CoreError::Internal(format!("writer recv: {e}")))? } - // WHY allow(significant_drop_tightening): same Mutex-guard lifetime - // reason as upsert_tag. - #[allow(clippy::significant_drop_tightening)] fn delete_tag(&self, tag_id: Uuid, device: DeviceId) -> Result<(), CoreError> { - let mut conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; - - // WHY BEGIN IMMEDIATE: delete_tag is a pure UPDATE, but - // IMMEDIATE avoids a write-lock upgrade race that DEFERRED can - // trigger under WAL. Consistent with all other write paths. - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|e| CoreError::Internal(format!("begin immediate: {e}")))?; - - let now = now_iso(); - let dev_str = device.0.to_string(); - tx.execute( - "UPDATE tags SET deleted_at = ?1, updated_at = ?1, device_id = ?2 - WHERE id = ?3 AND deleted_at IS NULL", - rusqlite::params![now, dev_str, tag_id.to_string()], - ) - .map_err(Error::from)?; - - tx.commit() - .map_err(|e| CoreError::Internal(format!("commit: {e}")))?; - + let (reply_tx, reply_rx) = flume::bounded::>(1); + self.writer + .send(WriteCmd::Tag(TagWriteCmd::DeleteTag { + tag_id, + device, + reply: reply_tx, + })) + .map_err(|e| CoreError::Internal(format!("writer send: {e}")))?; + // WHY drop the rows_changed `u64`: the `TagRepository` port + // returns `Result<(), CoreError>` on delete today. The writer + // still surfaces `changes()` so a future port widening is an + // additive change in this module only. + reply_rx + .recv() + .map_err(|e| CoreError::Internal(format!("writer recv: {e}")))??; Ok(()) } - // WHY allow(significant_drop_tightening): same Mutex-guard lifetime - // reason as upsert_tag. - #[allow(clippy::significant_drop_tightening)] fn attach(&self, hash: &BlakeHash, tag_id: Uuid, device: DeviceId) -> Result<(), CoreError> { - let hash_hex = hash.to_hex(); - let tag_id_str = tag_id.to_string(); - - let mut conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; - - // WHY BEGIN IMMEDIATE: SELECT-then-INSERT must be atomic to - // prevent duplicate active (hash, tag_id) rows under concurrency. - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|e| CoreError::Internal(format!("begin immediate: {e}")))?; - - let existing: Option = tx - .query_row( - "SELECT id FROM file_tags - WHERE blake3_hash = ?1 AND tag_id = ?2 AND deleted_at IS NULL", - [&hash_hex, &tag_id_str], - |row| row.get(0), - ) - .optional() - .map_err(Error::from)?; - - if existing.is_none() { - let id = Uuid::now_v7(); - let now = now_iso(); - let dev_str = device.0.to_string(); - tx.execute( - "INSERT INTO file_tags (id, blake3_hash, tag_id, first_seen, updated_at, device_id) - VALUES (?1, ?2, ?3, ?4, ?4, ?5)", - rusqlite::params![id.to_string(), hash_hex, tag_id_str, now, dev_str], - ) - .map_err(Error::from)?; - } - - tx.commit() - .map_err(|e| CoreError::Internal(format!("commit: {e}")))?; - + let (reply_tx, reply_rx) = flume::bounded::>(1); + self.writer + .send(WriteCmd::Tag(TagWriteCmd::Attach { + hash: *hash, + tag_id, + device, + reply: reply_tx, + })) + .map_err(|e| CoreError::Internal(format!("writer send: {e}")))?; + // WHY drop rows_changed: same as delete_tag — port returns `()`. + reply_rx + .recv() + .map_err(|e| CoreError::Internal(format!("writer recv: {e}")))??; Ok(()) } - // WHY allow(significant_drop_tightening): same Mutex-guard lifetime - // reason as upsert_tag. - #[allow(clippy::significant_drop_tightening)] fn detach(&self, hash: &BlakeHash, tag_id: Uuid, device: DeviceId) -> Result<(), CoreError> { - let hash_hex = hash.to_hex(); - let tag_id_str = tag_id.to_string(); - - let mut conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; - - // WHY BEGIN IMMEDIATE: detach is a pure UPDATE (no preceding - // SELECT), but IMMEDIATE avoids a write-lock upgrade race that - // DEFERRED can trigger under WAL. Consistent with all other - // write paths in this repo. - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|e| CoreError::Internal(format!("begin immediate: {e}")))?; - - let now = now_iso(); - let dev_str = device.0.to_string(); - tx.execute( - "UPDATE file_tags SET deleted_at = ?1, updated_at = ?1, device_id = ?2 - WHERE blake3_hash = ?3 AND tag_id = ?4 AND deleted_at IS NULL", - rusqlite::params![now, dev_str, hash_hex, tag_id_str], - ) - .map_err(Error::from)?; - - tx.commit() - .map_err(|e| CoreError::Internal(format!("commit: {e}")))?; - + let (reply_tx, reply_rx) = flume::bounded::>(1); + self.writer + .send(WriteCmd::Tag(TagWriteCmd::Detach { + hash: *hash, + tag_id, + device, + reply: reply_tx, + })) + .map_err(|e| CoreError::Internal(format!("writer send: {e}")))?; + // WHY drop rows_changed: same as delete_tag — port returns `()`. + reply_rx + .recv() + .map_err(|e| CoreError::Internal(format!("writer recv: {e}")))??; Ok(()) } - // WHY allow(significant_drop_tightening): the Mutex guard `conn` must - // outlive the `stmt` and row-iteration borrows that hold a reference - // through it. Dropping `conn` earlier would invalidate those borrows. - #[allow(clippy::significant_drop_tightening)] fn list_tags(&self) -> Result, CoreError> { - let conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; + // WHY a pool checkout here (no writer hop): `list_tags` is a + // pure SELECT. Reads go directly through the `r2d2_sqlite` pool + // (spec §3.5). `PooledConnection` derefs to + // `rusqlite::Connection`, so the SQL body is lifted verbatim + // from the pre-Batch-C impl. + let conn = self.reads.get()?; let mut stmt = conn .prepare( @@ -261,10 +165,6 @@ impl TagRepository for SqliteTagRepository { Ok(tags) } - // WHY allow(significant_drop_tightening): the Mutex guard `conn` must - // outlive the `stmt` and row-iteration borrows that hold a reference - // through it. - #[allow(clippy::significant_drop_tightening)] fn tags_for_hashes( &self, hashes: &[BlakeHash], @@ -276,10 +176,7 @@ impl TagRepository for SqliteTagRepository { return Ok(HashMap::new()); } - let conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; + let conn = self.reads.get()?; let placeholders: String = hashes .iter() @@ -329,14 +226,8 @@ impl TagRepository for SqliteTagRepository { Ok(map) } - // WHY allow(significant_drop_tightening): the Mutex guard `conn` must - // outlive the `stmt` and row-iteration borrows through it. - #[allow(clippy::significant_drop_tightening)] fn files_with_tag(&self, tag_id: Uuid) -> Result, CoreError> { - let conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; + let conn = self.reads.get()?; let mut stmt = conn .prepare( @@ -360,14 +251,8 @@ impl TagRepository for SqliteTagRepository { Ok(hashes) } - // WHY allow(significant_drop_tightening): the Mutex guard `conn` - // must outlive the query_row call that borrows through it. - #[allow(clippy::significant_drop_tightening)] fn count_files_for_tag(&self, tag_id: Uuid) -> Result { - let conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; + let conn = self.reads.get()?; let count: i64 = conn .query_row( @@ -387,14 +272,28 @@ impl TagRepository for SqliteTagRepository { )] #[cfg(test)] mod tests { - use std::sync::{Arc, Barrier}; + use std::sync::Arc; - use super::*; + use perima_core::EventBus; + use tempfile::TempDir; - fn test_db() -> (tempfile::TempDir, SqliteTagRepository) { + use super::*; + use crate::pool::ReadPool; + use crate::test_utils::NoopBus; + use crate::writer::{SqliteWriter, SqliteWriterHandle}; + + /// Test harness: tempdir-backed DB, writer actor, read pool, repo. + /// + /// WHY tempfile-on-disk (not in-memory): writer + pool must share + /// the same DB file; `:memory:` is per-connection private. + fn test_db() -> (TempDir, SqliteTagRepository, SqliteWriterHandle) { let td = tempfile::tempdir().expect("tempdir"); - let conn = crate::connection::open_and_migrate(&td.path().join("test.db")).expect("open"); - (td, SqliteTagRepository::new(conn)) + let db_path = td.path().join("test.db"); + let bus: Arc = Arc::new(NoopBus); + let writer = SqliteWriter::start(&db_path, bus).expect("writer start"); + let reads = ReadPool::open(&db_path).expect("pool open"); + let repo = SqliteTagRepository::new(writer.sender(), reads); + (td, repo, writer) } fn device() -> DeviceId { @@ -411,7 +310,7 @@ mod tests { #[test] fn upsert_tag_inserts_new() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let tag = repo.upsert_tag("Vacation", device()).expect("upsert"); assert_eq!(tag.name, "vacation"); assert!(!tag.first_seen.is_empty()); @@ -419,7 +318,7 @@ mod tests { #[test] fn upsert_tag_idempotent_on_repeat() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let t1 = repo.upsert_tag("trip", device()).expect("first"); let t2 = repo.upsert_tag("trip", device()).expect("second"); assert_eq!(t1.id, t2.id, "same id on repeat upsert"); @@ -427,7 +326,7 @@ mod tests { #[test] fn upsert_tag_normalizes_case_and_nfc() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let t1 = repo.upsert_tag("Vacation", device()).expect("upper"); let t2 = repo.upsert_tag("vacation", device()).expect("lower"); assert_eq!(t1.id, t2.id, "case variants resolve to the same tag"); @@ -435,7 +334,7 @@ mod tests { #[test] fn attach_inserts_new() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let tag = repo.upsert_tag("photo", device()).expect("upsert"); let h = sample_hash(); repo.attach(&h, tag.id, device()).expect("attach"); @@ -447,7 +346,7 @@ mod tests { #[test] fn attach_idempotent_on_repeat() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let tag = repo.upsert_tag("photo", device()).expect("upsert"); let h = sample_hash(); repo.attach(&h, tag.id, device()).expect("attach 1"); @@ -459,7 +358,7 @@ mod tests { #[test] fn detach_softdeletes() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let tag = repo.upsert_tag("trip", device()).expect("upsert"); let h = sample_hash(); repo.attach(&h, tag.id, device()).expect("attach"); @@ -473,7 +372,7 @@ mod tests { #[test] fn tags_for_hashes_single_hash() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let tag = repo.upsert_tag("landscape", device()).expect("upsert"); let h = sample_hash(); repo.attach(&h, tag.id, device()).expect("attach"); @@ -484,7 +383,7 @@ mod tests { #[test] fn tags_for_hashes_batch_returns_map() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let t1 = repo.upsert_tag("nature", device()).expect("t1"); let t2 = repo.upsert_tag("urban", device()).expect("t2"); let h1 = sample_hash(); @@ -500,14 +399,14 @@ mod tests { fn tags_for_hashes_empty_input_shortcircuits() { // WHY: SQL `IN ()` is a parse error in SQLite; this test ensures // the short-circuit returns an empty map without hitting the DB. - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let map = repo.tags_for_hashes(&[]).expect("empty"); assert!(map.is_empty()); } #[test] fn files_with_tag_returns_hashes() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let tag = repo.upsert_tag("archive", device()).expect("upsert"); let h1 = sample_hash(); let h2 = sample_hash_2(); @@ -520,7 +419,7 @@ mod tests { #[test] fn count_files_for_tag_is_o1() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let tag = repo.upsert_tag("raw", device()).expect("upsert"); let h1 = sample_hash(); let h2 = sample_hash_2(); @@ -529,22 +428,19 @@ mod tests { assert_eq!(repo.count_files_for_tag(tag.id).expect("count"), 2); } - // WHY allow(significant_drop_tightening): `conn` must outlive the - // `query_row` call that borrows through it; this is a test helper - // that accesses the Mutex directly to verify raw DB state. - #[allow(clippy::significant_drop_tightening)] #[test] fn delete_preserves_attachments() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let tag = repo.upsert_tag("keep", device()).expect("upsert"); let h = sample_hash(); repo.attach(&h, tag.id, device()).expect("attach"); repo.delete_tag(tag.id, device()).expect("delete tag"); // Verify the file_tags row still exists (soft-delete, not cascade). - // WHY raw SQL: tags_for_hashes filters out deleted tags, so we reach - // directly into the DB to confirm the row is still physically present. - let conn = repo.conn.lock().expect("lock"); + // WHY raw read via a pooled connection: tags_for_hashes filters + // out deleted tags, so we reach directly into the DB to confirm + // the row is still physically present. + let conn = repo.reads.get().expect("pool get"); let count: i64 = conn .query_row( "SELECT COUNT(*) FROM file_tags WHERE tag_id = ?1", @@ -557,7 +453,7 @@ mod tests { #[test] fn recreate_after_delete_yields_new_id() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let t1 = repo.upsert_tag("temp", device()).expect("first"); repo.delete_tag(t1.id, device()).expect("delete"); let t2 = repo.upsert_tag("temp", device()).expect("recreate"); @@ -565,28 +461,30 @@ mod tests { } #[test] - fn find_or_create_tag_concurrent_unique() { - let td = tempfile::tempdir().expect("tempdir"); - let db = td.path().join("test.db"); - let repo1 = - SqliteTagRepository::new(crate::connection::open_and_migrate(&db).expect("open 1")); - let repo2 = - SqliteTagRepository::new(crate::connection::open_and_migrate(&db).expect("open 2")); - let barrier = Arc::new(Barrier::new(2)); - let b1 = Arc::clone(&barrier); - let d1 = device(); - let h1 = std::thread::spawn(move || { - b1.wait(); - repo1.upsert_tag("race", d1) - }); - let b2 = barrier; - let d2 = device(); - let h2 = std::thread::spawn(move || { - b2.wait(); - repo2.upsert_tag("race", d2) - }); - let t1 = h1.join().unwrap().expect("t1"); - let t2 = h2.join().unwrap().expect("t2"); - assert_eq!(t1.id, t2.id, "both threads must resolve the same tag"); + fn upsert_tag_concurrent_unique() { + // WHY: two concurrent adapter HANDLES (cloned) calling + // upsert_tag with identical names must settle on ONE active + // tags row. Under the writer actor this is guaranteed by + // single-threaded serialization — the test still covers the + // observable behaviour contract. + use std::sync::{Arc as ArcStd, Barrier}; + use std::thread; + + let (_td, repo, _writer) = test_db(); + let repo = ArcStd::new(repo); + let barrier = ArcStd::new(Barrier::new(2)); + let mut handles = Vec::new(); + for _ in 0..2 { + let repo = ArcStd::clone(&repo); + let barrier = ArcStd::clone(&barrier); + handles.push(thread::spawn(move || -> Tag { + let dev = device(); + barrier.wait(); + repo.upsert_tag("race", dev).expect("upsert") + })); + } + let a = handles.remove(0).join().expect("thread a"); + let b = handles.remove(0).join().expect("thread b"); + assert_eq!(a.id, b.id, "both threads must resolve the same tag id"); } } diff --git a/crates/db/src/test_utils/mod.rs b/crates/db/src/test_utils/mod.rs new file mode 100644 index 0000000..180fcb6 --- /dev/null +++ b/crates/db/src/test_utils/mod.rs @@ -0,0 +1,7 @@ +//! Shared test utilities for `crates/db`. +//! +//! Gated on `#[cfg(any(test, feature = "test-utils"))]` from `lib.rs` +//! so production builds don't expose this module. + +pub mod noop_bus; +pub use noop_bus::NoopBus; diff --git a/crates/db/src/test_utils/noop_bus.rs b/crates/db/src/test_utils/noop_bus.rs new file mode 100644 index 0000000..5a9cffb --- /dev/null +++ b/crates/db/src/test_utils/noop_bus.rs @@ -0,0 +1,19 @@ +//! Shared test-utility bus that does nothing on emit. +//! +//! WHY shared: pre-Batch-E the same struct was duplicated 9 times across +//! `crates/db/src/{file_repo,tag_repo,metadata_repo,search_repo, +//! volume_repo}.rs`, `writer/mod.rs`, and 3 test files. Batch E +//! consolidates into one canonical impl. + +use perima_core::{AppEvent, CoreError, EventBus}; + +/// Minimal `EventBus` impl that drops every event. Used by writer tests +/// + repo tests that don't care about event observability. +#[derive(Debug, Default)] +pub struct NoopBus; + +impl EventBus for NoopBus { + fn emit(&self, _event: &AppEvent) -> Result<(), CoreError> { + Ok(()) + } +} diff --git a/crates/db/src/volume_repo.rs b/crates/db/src/volume_repo.rs index a1562a8..764262c 100644 --- a/crates/db/src/volume_repo.rs +++ b/crates/db/src/volume_repo.rs @@ -1,25 +1,34 @@ -//! `VolumeRepository` implementation backed by rusqlite. +//! `VolumeRepository` adapter — writer-actor + read-pool backed. //! -//! WHY: priority chain tries GUID first, then `fs_uuid`, then label+capacity. -//! v1 only has label+capacity; the structure exists so future sysinfo upgrades -//! or blkid integration slot in without refactoring the match logic. +//! Post-Batch-C Task 2. The struct holds two cheap-to-clone handles: +//! a [`flume::Sender`] connected to the single writer actor +//! (spec §3.1) and a [`ReadPool`] of read-only `r2d2_sqlite` +//! connections (spec §3.4). Writes build a `WriteCmd` variant with a +//! `flume::bounded(1)` reply channel and block on the reply. Reads run +//! SQL directly against a pooled connection. +//! +//! No `Mutex`. The legacy `::new(conn)` constructor is +//! deleted; every caller now supplies `(writer_sender, read_pool)`. -use std::sync::Mutex; +use std::path::Path; +use flume::Sender; use perima_core::{ CoreError, DeviceId, VolumeId, VolumeIdentifiers, VolumeRecord, VolumeRepository, }; -use rusqlite::{Connection, OptionalExtension}; +use crate::cmd::{VolumeWriteCmd, WriteCmd}; use crate::errors::Error; +use crate::pool::ReadPool; -/// Rusqlite-backed volume + volume-mount repository. +/// Writer-actor + read-pool backed volume + volume-mount repository. /// -/// WHY `Mutex`: `rusqlite::Connection` is `Send` but not `Sync`. Wrapping in -/// `Mutex` satisfies `Send + Sync` without `unsafe`, matching the pattern used -/// by [`crate::SqliteFileRepository`]. +/// Cheap to [`Clone`]: both fields (`flume::Sender`, `ReadPool`) are +/// internally refcounted. +#[derive(Clone)] pub struct SqliteVolumeRepository { - conn: Mutex, + writer: Sender, + reads: ReadPool, } impl std::fmt::Debug for SqliteVolumeRepository { @@ -30,239 +39,62 @@ impl std::fmt::Debug for SqliteVolumeRepository { } impl SqliteVolumeRepository { - /// Wrap an existing connection. Caller must have run migrations first. - pub const fn new(conn: Connection) -> Self { - Self { - conn: Mutex::new(conn), - } + /// Construct an adapter from a writer-command sender + a read pool. + /// + /// WHY no migration run here: migrations happen exactly once inside + /// [`crate::SqliteWriter::start`] BEFORE the writer thread spawns + /// (spec §3.6). The read pool is opened after migrations complete. + #[must_use] + pub const fn new(writer: Sender, reads: ReadPool) -> Self { + Self { writer, reads } } } -fn now_iso() -> String { - chrono::Utc::now().to_rfc3339() -} - -/// Lock the mutex, mapping poison to `CoreError::Internal`. -fn lock(conn: &Mutex) -> Result, CoreError> { - conn.lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}"))) -} - impl VolumeRepository for SqliteVolumeRepository { fn find_or_create( &self, ident: &VolumeIdentifiers, device: DeviceId, ) -> Result { - let mut conn = lock(&self.conn)?; - let now = now_iso(); - let dev_str = device.0.to_string(); - - // WHY BEGIN IMMEDIATE: SqliteVolumeRepository wraps one Connection - // per adapter, but the CLI and desktop spawn multiple repos against - // the same DB file. Without IMMEDIATE, two handles can both pass - // the SELECT "not found" check and INSERT duplicates. IMMEDIATE - // takes the reserved writer lock at BEGIN; the busy_timeout in - // `open_and_migrate` makes the loser wait rather than fail. - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(Error::from)?; - - // WHY: priority chain — GUID is the most stable identifier (survives - // reformatting on the same hardware). fs_uuid is next. label+capacity - // is the v1 fallback. Each arm SELECT-then-UPDATE-last-seen, or falls - // through to the next. - - // Arm 1: GPT partition GUID - if let Some(ref guid) = ident.gpt_partition_guid { - let existing: Option = tx - .query_row( - "SELECT volume_id FROM volumes - WHERE gpt_partition_guid = ?1 AND deleted_at IS NULL", - [guid], - |row| row.get(0), - ) - .optional() - .map_err(Error::from)?; - - if let Some(vol_id_str) = existing { - tx.execute( - "UPDATE volumes SET last_seen = ?1, updated_at = ?1, device_id = ?2 - WHERE volume_id = ?3", - rusqlite::params![now, dev_str, vol_id_str], - ) - .map_err(Error::from)?; - let vol_id = uuid::Uuid::parse_str(&vol_id_str) - .map_err(|e| CoreError::Internal(format!("bad volume uuid: {e}")))?; - tx.commit().map_err(Error::from)?; - return Ok(VolumeId(vol_id)); - } - } - - // Arm 2: Filesystem UUID - if let Some(ref fs_uuid) = ident.fs_uuid { - let existing: Option = tx - .query_row( - "SELECT volume_id FROM volumes - WHERE fs_uuid = ?1 AND deleted_at IS NULL", - [fs_uuid], - |row| row.get(0), - ) - .optional() - .map_err(Error::from)?; - - if let Some(vol_id_str) = existing { - tx.execute( - "UPDATE volumes SET last_seen = ?1, updated_at = ?1, device_id = ?2 - WHERE volume_id = ?3", - rusqlite::params![now, dev_str, vol_id_str], - ) - .map_err(Error::from)?; - let vol_id = uuid::Uuid::parse_str(&vol_id_str) - .map_err(|e| CoreError::Internal(format!("bad volume uuid: {e}")))?; - tx.commit().map_err(Error::from)?; - return Ok(VolumeId(vol_id)); - } - } - - // Arm 3: label + capacity (v1 primary matching path) - if let Some(ref label) = ident.label { - let cap_i64 = capacity_to_i64(ident.capacity_bytes)?; - let existing: Option = tx - .query_row( - "SELECT volume_id FROM volumes - WHERE volume_label = ?1 AND capacity_bytes = ?2 - AND deleted_at IS NULL", - rusqlite::params![label, cap_i64], - |row| row.get(0), - ) - .optional() - .map_err(Error::from)?; - - if let Some(vol_id_str) = existing { - tx.execute( - "UPDATE volumes SET last_seen = ?1, updated_at = ?1, device_id = ?2 - WHERE volume_id = ?3", - rusqlite::params![now, dev_str, vol_id_str], - ) - .map_err(Error::from)?; - let vol_id = uuid::Uuid::parse_str(&vol_id_str) - .map_err(|e| CoreError::Internal(format!("bad volume uuid: {e}")))?; - tx.commit().map_err(Error::from)?; - return Ok(VolumeId(vol_id)); - } - } - - // No match → INSERT new volume row. - let new_id = VolumeId::new(); - let new_id_str = new_id.0.to_string(); - let cap_i64 = capacity_to_i64(ident.capacity_bytes)?; - tx.execute( - "INSERT INTO volumes - (volume_id, gpt_partition_guid, fs_uuid, volume_label, - capacity_bytes, is_removable, last_seen, updated_at, device_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8)", - rusqlite::params![ - new_id_str, - ident.gpt_partition_guid, - ident.fs_uuid, - ident.label, - cap_i64, - i64::from(ident.is_removable), - now, - dev_str, - ], - ) - .map_err(Error::from)?; - tx.commit().map_err(Error::from)?; - drop(conn); - - Ok(new_id) + let (reply_tx, reply_rx) = flume::bounded::>(1); + self.writer + .send(WriteCmd::Volume(VolumeWriteCmd::FindOrCreate { + identifiers: ident.clone(), + device, + reply: reply_tx, + })) + .map_err(|e| CoreError::Internal(format!("writer send: {e}")))?; + reply_rx + .recv() + .map_err(|e| CoreError::Internal(format!("writer recv: {e}")))? } fn record_mount( &self, volume: VolumeId, machine: DeviceId, - mount: &std::path::Path, + mount: &Path, ) -> Result<(), CoreError> { - // WHY: validate UTF-8 at the boundary. `to_string_lossy` silently - // replaces invalid bytes with U+FFFD, corrupting future identity - // matches. InvalidPath is the taxonomy hit used elsewhere for path - // problems that fail validation before we touch the DB. - let mount_str = mount.to_str().ok_or_else(|| { - CoreError::InvalidPath(format!( - "mount path is not valid UTF-8: {}", - mount.display() - )) - })?; - - let mut conn = lock(&self.conn)?; - let now = now_iso(); - let vol_str = volume.0.to_string(); - let machine_str = machine.0.to_string(); - - // WHY BEGIN IMMEDIATE: the read-modify-write sequence below - // (SELECT existing, soft-delete superseded rows, INSERT new row) is - // not atomic across connections under SQLite's default DEFERRED - // transaction. IMMEDIATE acquires the writer lock at BEGIN time so - // concurrent repo handles serialize instead of racing to insert - // duplicates. See `open_and_migrate` busy_timeout — the retry - // callback makes the second writer wait, not fail. - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(Error::from)?; - - // WHY: soft-delete any active mount row for the same - // (volume_id, machine_id) whose mount_path differs from the new one. - // CRDT rules forbid hard-delete on mutable rows; retired mounts - // remain observable to future sync with deleted_at set. - tx.execute( - "UPDATE volume_mounts - SET deleted_at = ?1, updated_at = ?1, device_id = ?2 - WHERE volume_id = ?3 AND machine_id = ?4 - AND mount_path <> ?5 AND deleted_at IS NULL", - rusqlite::params![now, machine_str, vol_str, machine_str, mount_str], - ) - .map_err(Error::from)?; - - // WHY: app-level uniqueness on (volume_id, machine_id, mount_path, - // deleted_at IS NULL) replaces a UNIQUE constraint that CLAUDE.md - // forbids on mutable columns. SELECT-then-INSERT under IMMEDIATE is - // race-safe because only one writer can hold the reserved lock. - let existing: Option = tx - .query_row( - "SELECT id FROM volume_mounts - WHERE volume_id = ?1 AND machine_id = ?2 - AND mount_path = ?3 AND deleted_at IS NULL", - rusqlite::params![vol_str, machine_str, mount_str], - |row| row.get(0), - ) - .optional() - .map_err(Error::from)?; - - if existing.is_none() { - let new_id = perima_core::ids::new_id().to_string(); - tx.execute( - "INSERT INTO volume_mounts - (id, volume_id, machine_id, mount_path, first_seen, updated_at, device_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6)", - rusqlite::params![new_id, vol_str, machine_str, mount_str, now, machine_str,], - ) - .map_err(Error::from)?; - } - - tx.commit().map_err(Error::from)?; - drop(conn); - - Ok(()) + let (reply_tx, reply_rx) = flume::bounded::>(1); + self.writer + .send(WriteCmd::Volume(VolumeWriteCmd::RecordMount { + volume, + device: machine, + mount: mount.to_path_buf(), + reply: reply_tx, + })) + .map_err(|e| CoreError::Internal(format!("writer send: {e}")))?; + reply_rx + .recv() + .map_err(|e| CoreError::Internal(format!("writer recv: {e}")))? } - // WHY allow(significant_drop_tightening): the Mutex guard `conn` must - // outlive `stmt` and `rows` — same pattern as SqliteFileRepository. - #[allow(clippy::significant_drop_tightening)] fn list(&self, machine: DeviceId) -> Result, CoreError> { - let conn = lock(&self.conn)?; + // WHY a pool checkout here (no writer hop): `list` is a pure + // SELECT. Reads go directly through the `r2d2_sqlite` pool + // (spec §3.5). `PooledConnection` derefs to `rusqlite::Connection`, + // so the SQL body is lifted verbatim from the pre-Batch-C impl. + let conn = self.reads.get()?; let machine_str = machine.0.to_string(); let mut stmt = conn @@ -329,19 +161,35 @@ impl VolumeRepository for SqliteVolumeRepository { } } -fn capacity_to_i64(cap: u64) -> Result { - i64::try_from(cap).map_err(|_| CoreError::Internal(format!("capacity {cap} overflows i64"))) -} - #[cfg(test)] +#[allow(clippy::unwrap_used)] // Test code; unwrap panics signal bugs. mod tests { - use super::*; - use crate::connection::open_and_migrate; + use std::sync::Arc; - fn test_db() -> (tempfile::TempDir, SqliteVolumeRepository) { + use perima_core::EventBus; + use tempfile::TempDir; + + use super::*; + use crate::pool::ReadPool; + use crate::test_utils::NoopBus; + use crate::writer::{SqliteWriter, SqliteWriterHandle}; + + /// Test harness: tempdir-backed DB, writer actor, read pool, repo. + /// + /// WHY tempfile-on-disk (not in-memory): the writer actor opens its + /// connection via `Connection::open_in_memory()` in the test-only + /// `start_in_memory` helper, which is PER-CONNECTION private memory. + /// A separate read pool can't see that memory DB. A tempfile-backed + /// DB lets writer + pool share the same file; WAL mode keeps both + /// sides cheap. + fn test_db() -> (TempDir, SqliteVolumeRepository, SqliteWriterHandle) { let td = tempfile::tempdir().expect("tempdir"); - let conn = open_and_migrate(&td.path().join("test.db")).expect("open"); - (td, SqliteVolumeRepository::new(conn)) + let db_path = td.path().join("test.db"); + let bus: Arc = Arc::new(NoopBus); + let writer = SqliteWriter::start(&db_path, bus).expect("writer start"); + let reads = ReadPool::open(&db_path).expect("pool open"); + let repo = SqliteVolumeRepository::new(writer.sender(), reads); + (td, repo, writer) } fn device() -> DeviceId { @@ -360,7 +208,7 @@ mod tests { #[test] fn find_or_create_inserts_new() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let ident = label_cap_ident("MY_DRIVE", 1_000_000); let vol_id = repo .find_or_create(&ident, device()) @@ -371,7 +219,7 @@ mod tests { #[test] fn find_or_create_matches_on_label_capacity() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let ident = label_cap_ident("BACKUP_SSD", 2_000_000_000); let first = repo.find_or_create(&ident, dev).expect("first"); @@ -384,7 +232,7 @@ mod tests { #[test] fn find_or_create_guid_trumps_label() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); // Insert volume A with GUID "aaa…" + label "A". @@ -419,7 +267,7 @@ mod tests { #[test] fn record_mount_inserts_new() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let ident = label_cap_ident("MOUNT_TEST", 100_000); let vol_id = repo.find_or_create(&ident, dev).expect("create"); @@ -436,7 +284,7 @@ mod tests { #[test] fn record_mount_unchanged_on_repeat() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let ident = label_cap_ident("MOUNT_REPEAT", 200_000); let vol_id = repo.find_or_create(&ident, dev).expect("create"); @@ -449,74 +297,13 @@ mod tests { assert_eq!(records[0].mounts_on_this_machine.len(), 1); } - #[test] - fn find_or_create_concurrent_unique() { - // WHY: two concurrent repo handles calling find_or_create with - // identical label+capacity must settle on ONE active volume row. - // A 2-party `Barrier` forces both threads past the initial SELECT - // before either INSERT, reproducing the race that `BEGIN IMMEDIATE` - // must close. Pre-fix: both threads SELECT "not found", both - // INSERT, producing two active rows with different UUIDs. Post-fix: - // IMMEDIATE serializes the tx; the second thread re-reads after - // the first commits and reuses the existing id. - use std::sync::{Arc, Barrier}; - use std::thread; - - let td = tempfile::tempdir().expect("tempdir"); - let db_path = td.path().join("race.db"); - - // Drive migrations once up front so both worker connections skip them. - { - let _ = open_and_migrate(&db_path).expect("migrate"); - } - - let dev = device(); - let barrier = Arc::new(Barrier::new(2)); - let mut handles = Vec::new(); - for _ in 0..2 { - let db_path = db_path.clone(); - let barrier = Arc::clone(&barrier); - handles.push(thread::spawn(move || -> VolumeId { - let conn = open_and_migrate(&db_path).expect("open"); - let repo = SqliteVolumeRepository::new(conn); - let ident = label_cap_ident("RACE_VOL", 42_000); - barrier.wait(); - repo.find_or_create(&ident, dev).expect("find_or_create") - })); - } - let a = handles.remove(0).join().expect("thread a"); - let b = handles.remove(0).join().expect("thread b"); - assert_eq!( - a, b, - "concurrent find_or_create must resolve to a single VolumeId" - ); - - // Cross-check: the DB must contain exactly one active row for the - // label+capacity combination (regardless of whether threads produced - // the same id twice or one thread reused the other's insert). - let conn = open_and_migrate(&db_path).expect("verify open"); - let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM volumes - WHERE volume_label = ?1 AND capacity_bytes = ?2 - AND deleted_at IS NULL", - rusqlite::params!["RACE_VOL", 42_000_i64], - |r| r.get(0), - ) - .expect("count"); - assert_eq!( - count, 1, - "exactly one active volume row must exist after concurrent inserts" - ); - } - #[test] fn record_mount_retires_superseded_path() { // WHY: remount on a new path for the same (volume, machine) must // soft-delete the prior row rather than leaving two active mount // rows for one device. `list` reads only active mounts, so the // observable contract is: after remount only the new path surfaces. - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let ident = label_cap_ident("SUPERSEDE", 100_000); let vol_id = repo.find_or_create(&ident, dev).expect("create"); @@ -539,10 +326,8 @@ mod tests { fn record_mount_idempotent_on_same_path() { // WHY: the retirement sweep must NOT soft-delete a row whose // mount_path equals the new one, or remount-to-same-path would - // churn the row and update updated_at. Explicit test to pin the - // "idempotent" behaviour beyond `record_mount_unchanged_on_repeat` - // which only asserts the list count. - let (_td, repo) = test_db(); + // churn the row and update updated_at. + let (_td, repo, _writer) = test_db(); let dev = device(); let ident = label_cap_ident("IDEMPOTENT", 50_000); let vol_id = repo.find_or_create(&ident, dev).expect("create"); @@ -565,14 +350,11 @@ mod tests { fn record_mount_rejects_non_utf8_path() { // WHY: Linux paths are arbitrary bytes; `to_string_lossy` silently // replaces invalid UTF-8 with U+FFFD, corrupting identity matching. - // `record_mount` must reject non-UTF8 paths at the boundary with - // `CoreError::InvalidPath` so callers surface the problem instead of - // silently storing an ambiguous row. use std::ffi::OsString; use std::os::unix::ffi::OsStringExt; use std::path::PathBuf; - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let ident = label_cap_ident("NONUTF8", 100_000); let vol_id = repo.find_or_create(&ident, dev).expect("create"); @@ -591,7 +373,7 @@ mod tests { #[test] fn list_returns_volumes_with_mounts() { - let (_td, repo) = test_db(); + let (_td, repo, _writer) = test_db(); let dev = device(); let ident = label_cap_ident("LIST_TEST", 999_000); let vol_id = repo.find_or_create(&ident, dev).expect("create"); @@ -608,4 +390,48 @@ mod tests { vec![std::path::PathBuf::from("/mnt/listtest")] ); } + + #[test] + fn find_or_create_concurrent_unique() { + // WHY: two concurrent adapter HANDLES (same writer+pool, cloned) + // calling find_or_create with identical label+capacity must + // settle on ONE active volume row. Under the writer actor this + // is guaranteed by single-threaded serialization — the test + // still covers the observable behaviour contract. + use std::sync::{Arc as ArcStd, Barrier}; + use std::thread; + + let (_td, repo, _writer) = test_db(); + let dev = device(); + + let repo = ArcStd::new(repo); + let barrier = ArcStd::new(Barrier::new(2)); + let mut handles = Vec::new(); + for _ in 0..2 { + let repo = ArcStd::clone(&repo); + let barrier = ArcStd::clone(&barrier); + handles.push(thread::spawn(move || -> VolumeId { + let ident = label_cap_ident("RACE_VOL", 42_000); + barrier.wait(); + repo.find_or_create(&ident, dev).expect("find_or_create") + })); + } + let a = handles.remove(0).join().expect("thread a"); + let b = handles.remove(0).join().expect("thread b"); + assert_eq!( + a, b, + "concurrent find_or_create must resolve to a single VolumeId" + ); + + // Cross-check: exactly one active row for label+capacity. + let records = repo.list(dev).expect("list"); + let matching = records + .iter() + .filter(|r| r.label.as_deref() == Some("RACE_VOL") && r.capacity_bytes == 42_000) + .count(); + assert_eq!( + matching, 1, + "exactly one active volume row must exist after concurrent inserts" + ); + } } diff --git a/crates/db/src/writer/file.rs b/crates/db/src/writer/file.rs new file mode 100644 index 0000000..74157ae --- /dev/null +++ b/crates/db/src/writer/file.rs @@ -0,0 +1,534 @@ +//! Writer-side handler for [`crate::cmd::FileWriteCmd`]. +//! +//! Lifts the SQL bodies that previously lived inside +//! `impl FileRepository for SqliteFileRepository::{upsert_file, +//! upsert_location}` and the inherent methods +//! `SqliteFileRepository::{update_location_status, update_location_path, +//! migrate_sentinel_row}` (pre-Batch-C) into writer-owned functions. The +//! writer thread holds the sole writable [`rusqlite::Connection`] +//! (spec §3.1); the adapter on the caller-side is now a thin send → +//! recv shim (see `crates/db/src/file_repo.rs`). +//! +//! # HLC semantics +//! +//! Each command computes `let hlc = Hlc::now().pack();` ONCE at the top +//! of [`handle`] and binds the same packed value to every `files` / +//! `file_locations` row written by the command — one HLC value per +//! user-visible logical event (spec §3.7). Per V009: +//! +//! - `files.hlc` bumps on INSERT and on UPDATE in +//! [`crate::cmd::FileWriteCmd::UpsertFile`]. +//! - `file_locations.hlc` bumps on INSERT and on UPDATE in +//! [`crate::cmd::FileWriteCmd::UpsertLocation`], +//! [`crate::cmd::FileWriteCmd::UpdateLocationStatus`], +//! [`crate::cmd::FileWriteCmd::UpdateLocationPath`], and +//! [`crate::cmd::FileWriteCmd::MigrateSentinelRow`]. +//! - The `Unchanged` arm in `UpsertFile` and `UpsertLocation` skips +//! every write; no `hlc` is consumed and the prior value is preserved +//! (same logical event did not fire). +//! - `UpdateLocationPath` collision path (soft-delete source row when +//! destination already exists): binds `file_locations.hlc` on the +//! soft-delete UPDATE. +//! - `volume_mounts` has no `hlc` column per V009 (device-local) — NOT +//! touched by this module. +//! +//! # Events +//! +//! After a successful COMMIT on any file-shaped write that actually +//! changed state, the writer emits +//! [`perima_core::AppEvent::IndexInvalidated`] with +//! [`perima_core::InvalidationReason::FilesChanged`] — the coarse v1 +//! signal that file-shaped query indexes (file grid, location list, +//! status filters) are stale. +//! +//! `FileEvent` (filesystem-watcher events: Created / Modified / +//! Deleted / Renamed) is a SEPARATE concern emitted by the watcher +//! (now wrapped as `AppEvent::File`). The writer does NOT re-emit +//! `FileEvent` — that would double-fire on every watcher-triggered +//! status flip. The `IndexInvalidated::FilesChanged` signal is the +//! cache-invalidation hint for query-state, not a re-broadcast of +//! the underlying filesystem change. +//! +//! WHY emit on every variant: all five `FileWriteCmd` variants +//! mutate `files` or `file_locations` — both are read by the +//! frontend file grid + location list. Coarse invalidation is the +//! v1 contract; per-row surgical invalidation is a Batch H decision. + +use std::sync::Arc; + +use perima_core::{ + AppEvent, BlakeHash, CoreError, DeviceId, EventBus, HashedFile, Hlc, InvalidationReason, + LocationStatus, MediaPath, UpsertOutcome, VolumeId, +}; +use rusqlite::{Connection, OptionalExtension}; + +use crate::cmd::FileWriteCmd; +use crate::errors::Error; + +/// Writer-side dispatch for [`FileWriteCmd`]. Consumes the command +/// (the reply channel lives inside each variant) and sends the result +/// back on the caller's reply channel. +/// +/// After successful writes that actually change state (i.e. did not +/// land on the `Unchanged` arm of an upsert and did not no-op a path/ +/// status update), this fn emits [`AppEvent::IndexInvalidated`] with +/// [`InvalidationReason::FilesChanged`] AFTER the COMMIT — see spec +/// §3.3. +#[allow(clippy::needless_pass_by_value)] +// WHY allow cognitive_complexity: the body is a single five-arm +// `match`; each arm is one impl call + a small emit + a reply send. +// The repetition lives in the data shape (5 sub-variants), not in +// per-arm logic. Splitting into per-arm helpers either pushes every +// helper past `clippy::too_many_arguments` (8 params: conn, payload +// fields, hlc, bus, reply) or buys cleanliness via reference-passing +// `reply` in a way that obscures the consume-on-send semantics. +#[allow(clippy::cognitive_complexity)] +pub(super) fn handle(conn: &mut Connection, cmd: FileWriteCmd, bus: &Arc) { + // WHY one HLC per command (not per row): the "one HLC per + // user-visible logical event" invariant from spec §3.7. A single + // upsert_file may INSERT a new row OR UPDATE an existing one; both + // paths bind the same `hlc` value. The `Unchanged` arm skips + // every write — no `hlc` is consumed. + let hlc = Hlc::now().pack(); + + match cmd { + FileWriteCmd::UpsertFile { + file, + device, + reply, + } => { + let out = upsert_file_impl(conn, &file, device, hlc); + // WHY emit gated on Inserted | Updated: the `Unchanged` + // arm writes zero rows and does not bump hlc — not a + // logical event per spec §3.3. + if matches!(&out, Ok(o) if !matches!(o, UpsertOutcome::Unchanged)) { + emit_files_changed(bus, "upsert_file"); + } + if reply.send(out).is_err() { + // WHY debug (not warn): caller dropped its reply + // handle — e.g. CLI aborted mid-command. The write + // already committed; nothing actionable. + tracing::debug!("file upsert_file reply channel closed before send"); + } + } + FileWriteCmd::UpsertLocation { + hash, + volume, + path, + device, + reply, + } => { + let out = upsert_location_impl(conn, &hash, volume, &path, device, hlc); + if matches!(&out, Ok(o) if !matches!(o, UpsertOutcome::Unchanged)) { + emit_files_changed(bus, "upsert_location"); + } + if reply.send(out).is_err() { + tracing::debug!("file upsert_location reply channel closed before send"); + } + } + FileWriteCmd::UpdateLocationStatus { + volume, + path, + status, + device, + reply, + } => { + let out = update_location_status_impl(conn, volume, &path, status, device, hlc); + // WHY emit gated on rows > 0: a status update against a + // non-existent (volume, path) pair writes zero rows. + if matches!(&out, Ok(rows) if *rows > 0) { + emit_files_changed(bus, "update_location_status"); + } + if reply.send(out).is_err() { + tracing::debug!("file update_location_status reply channel closed before send"); + } + } + FileWriteCmd::UpdateLocationPath { + volume, + old_path, + new_path, + device, + reply, + } => { + let out = update_location_path_impl(conn, volume, &old_path, &new_path, device, hlc); + if matches!(&out, Ok(rows) if *rows > 0) { + emit_files_changed(bus, "update_location_path"); + } + if reply.send(out).is_err() { + tracing::debug!("file update_location_path reply channel closed before send"); + } + } + FileWriteCmd::MigrateSentinelRow { + path, + real_volume, + device, + reply, + } => { + let out = migrate_sentinel_row_impl(conn, &path, real_volume, device, hlc); + if matches!(&out, Ok(rows) if *rows > 0) { + emit_files_changed(bus, "migrate_sentinel_row"); + } + if reply.send(out).is_err() { + tracing::debug!("file migrate_sentinel_row reply channel closed before send"); + } + } + } +} + +/// Emit `IndexInvalidated::FilesChanged` and log on emit failure. +/// +/// `who` identifies the calling sub-command for log scoping. Failed +/// emits log-only — the COMMIT already landed, the caller already got +/// (or is about to get) its reply, and other handlers should still fire. +fn emit_files_changed(bus: &Arc, who: &'static str) { + if let Err(e) = bus.emit(&AppEvent::IndexInvalidated { + reason: InvalidationReason::FilesChanged, + }) { + tracing::warn!(?e, who, "post-commit emit failed: FilesChanged"); + } +} + +/// ISO-8601 UTC timestamp used for `updated_at` / `first_seen` / +/// `deleted_at`. Matches the pre-Batch-C adapter's `now_iso` helper. +fn now_iso() -> String { + chrono::Utc::now().to_rfc3339() +} + +/// Convert `FileSize` (`u64`) to the `i64` that `SQLite` stores. +/// +/// WHY: `SQLite` integers are signed 64-bit. A file larger than `i64::MAX` +/// (~8 EiB) cannot exist on current hardware; we propagate as `Internal` +/// rather than silently wrapping. +fn size_to_i64(size: perima_core::FileSize) -> Result { + i64::try_from(size.0) + .map_err(|_| CoreError::Internal(format!("file size {} overflows i64", size.0))) +} + +/// Convert a `LocationStatus` to its DB string representation. +/// +/// WHY: status values are stored as lowercase strings so they are +/// human-readable in `SQLite` tooling and stable across future Rust +/// refactors. The deserializer in `list_file_locations` (read path) mirrors it. +const fn status_to_str(status: LocationStatus) -> &'static str { + match status { + LocationStatus::Active => "active", + LocationStatus::Missing => "missing", + LocationStatus::Moved => "moved", + LocationStatus::Stale => "stale", + } +} + +/// Writer-side body for [`FileWriteCmd::UpsertFile`]. Lifted verbatim +/// from the pre-Batch-C `SqliteFileRepository::upsert_file` impl with +/// `hlc = ?` bound on the INSERT and UPDATE paths. +/// +/// Returns `UpsertOutcome::Inserted` / `Updated` / `Unchanged`. +/// `Unchanged` skips all writes — `hlc` is not rebound (prior value +/// preserved, per spec §3.7). +fn upsert_file_impl( + conn: &mut Connection, + file: &HashedFile, + device: DeviceId, + hlc: i64, +) -> Result { + // WHY BEGIN IMMEDIATE: historical rationale (pre-Batch-C) guarded + // against two adapter handles racing on SELECT-then-INSERT. The + // single writer actor now guarantees serialization, BUT retaining + // IMMEDIATE is cheap and documents the "only one write tx at a + // time" expectation at the SQL boundary. + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(Error::from)?; + + let hash_hex = file.hash.to_hex(); + let now = now_iso(); + let dev_str = device.0.to_string(); + let size_i64 = size_to_i64(file.discovered.size)?; + + // WHY two-statement SELECT-then-INSERT/UPDATE: `SQLite`'s `changes()` + // cannot distinguish a fresh INSERT from a conflict-triggered UPDATE + // — both report 1. The SELECT lets us classify Inserted / Updated / + // Unchanged precisely. + let existing: Option<(i64, String)> = tx + .query_row( + "SELECT file_size, device_id FROM files WHERE blake3_hash = ?1", + [&hash_hex], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(Error::from)?; + + let outcome = match existing { + None => { + tx.execute( + "INSERT INTO files + (blake3_hash, file_size, first_seen, updated_at, device_id, hlc) + VALUES (?1, ?2, ?3, ?3, ?4, ?5)", + rusqlite::params![hash_hex, size_i64, now, dev_str, hlc], + ) + .map_err(Error::from)?; + UpsertOutcome::Inserted + } + Some((existing_size, ref existing_dev)) + if existing_size == size_i64 && *existing_dev == dev_str => + { + // WHY no hlc write on Unchanged: same logical event did + // not fire — preserving the prior hlc matches the tag / + // metadata upsert semantics (spec §3.7). + UpsertOutcome::Unchanged + } + Some(_) => { + tx.execute( + "UPDATE files + SET file_size = ?1, updated_at = ?2, device_id = ?3, hlc = ?4 + WHERE blake3_hash = ?5", + rusqlite::params![size_i64, now, dev_str, hlc, hash_hex], + ) + .map_err(Error::from)?; + UpsertOutcome::Updated + } + }; + + tx.commit().map_err(Error::from)?; + Ok(outcome) +} + +/// Writer-side body for [`FileWriteCmd::UpsertLocation`]. Lifted verbatim +/// from the pre-Batch-C `SqliteFileRepository::upsert_location` impl with +/// `hlc = ?` bound on the INSERT and UPDATE paths. +/// +/// Three arms: None → INSERT (destination wins at app level), hash+device +/// match → `Unchanged` (skip write, preserve prior `hlc`), else → UPDATE +/// the existing row by id. No collision-soft-delete here — that only +/// fires in [`update_location_path_impl`] when an explicit rename would +/// introduce a duplicate active (volume, path). +fn upsert_location_impl( + conn: &mut Connection, + hash: &BlakeHash, + volume: VolumeId, + path: &MediaPath, + device: DeviceId, + hlc: i64, +) -> Result { + let hash_hex = hash.to_hex(); + let vol_str = volume.0.to_string(); + let path_str = path.as_str(); + let dev_str = device.0.to_string(); + let now = now_iso(); + + // WHY BEGIN IMMEDIATE: the SELECT-then-INSERT/UPDATE sequence must + // serialize across callers. The single writer actor already serializes + // commands, but IMMEDIATE is cheap and documents the intent. + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(Error::from)?; + + // WHY app-level uniqueness on (volume_id, relative_path, + // deleted_at IS NULL) replaces a UNIQUE constraint that + // CLAUDE.md forbids on mutable columns. Safe under IMMEDIATE. + let existing: Option<(String, String, String)> = tx + .query_row( + "SELECT id, blake3_hash, device_id FROM file_locations + WHERE volume_id = ?1 AND relative_path = ?2 AND deleted_at IS NULL", + rusqlite::params![vol_str, path_str], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional() + .map_err(Error::from)?; + + let outcome = match existing { + None => { + let id = perima_core::ids::new_id().to_string(); + tx.execute( + "INSERT INTO file_locations + (id, blake3_hash, volume_id, relative_path, status, + first_seen, updated_at, device_id, hlc) + VALUES (?1, ?2, ?3, ?4, 'active', ?5, ?5, ?6, ?7)", + rusqlite::params![id, hash_hex, vol_str, path_str, now, dev_str, hlc], + ) + .map_err(Error::from)?; + UpsertOutcome::Inserted + } + Some((_, ref existing_hash, ref existing_dev)) + if *existing_hash == hash_hex && *existing_dev == dev_str => + { + // WHY no hlc write on Unchanged: same logical event did + // not fire — preserving the prior hlc matches all other + // upsert Unchanged semantics (spec §3.7). + UpsertOutcome::Unchanged + } + Some((ref row_id, _, _)) => { + tx.execute( + "UPDATE file_locations + SET blake3_hash = ?1, updated_at = ?2, device_id = ?3, hlc = ?4 + WHERE id = ?5", + rusqlite::params![hash_hex, now, dev_str, hlc, row_id], + ) + .map_err(Error::from)?; + UpsertOutcome::Updated + } + }; + + tx.commit().map_err(Error::from)?; + Ok(outcome) +} + +/// Writer-side body for [`FileWriteCmd::UpdateLocationStatus`]. Lifted +/// verbatim from the pre-Batch-C `SqliteFileRepository::update_location_status` +/// impl with `hlc = ?` bound on the UPDATE. +/// +/// Returns the number of rows updated (0 if no matching row exists, 1 on success). +fn update_location_status_impl( + conn: &mut Connection, + volume: VolumeId, + path: &MediaPath, + status: LocationStatus, + device: DeviceId, + hlc: i64, +) -> Result { + // WHY BEGIN IMMEDIATE: a pure UPDATE (no preceding SELECT), but + // IMMEDIATE avoids a write-lock upgrade race under WAL. Consistent + // with all other write paths in this module. + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(Error::from)?; + + let vol_str = volume.0.to_string(); + let path_str = path.as_str(); + let status_str = status_to_str(status); + let dev_str = device.0.to_string(); + let now = now_iso(); + let n = tx + .execute( + "UPDATE file_locations + SET status = ?1, updated_at = ?2, device_id = ?3, hlc = ?4 + WHERE volume_id = ?5 AND relative_path = ?6 AND deleted_at IS NULL", + rusqlite::params![status_str, now, dev_str, hlc, vol_str, path_str], + ) + .map_err(Error::from)?; + + tx.commit().map_err(Error::from)?; + + // WHY: at most 1 active row per (volume, path) by app-level invariant. + u64::try_from(n).map_err(|_| CoreError::Internal(format!("rows_changed {n} is negative"))) +} + +/// Writer-side body for [`FileWriteCmd::UpdateLocationPath`]. Lifted verbatim +/// from the pre-Batch-C `SqliteFileRepository::update_location_path` impl with +/// `hlc = ?` bound on both branches. +/// +/// If an active row already exists at `new_path`, the source row is +/// soft-deleted and the destination row is left untouched — `hlc` IS +/// bound on the soft-delete UPDATE. If no collision: the path UPDATE +/// binds `hlc`. +/// +/// Returns the number of rows written (0 if no source row exists, 1 otherwise). +fn update_location_path_impl( + conn: &mut Connection, + volume: VolumeId, + old_path: &MediaPath, + new_path: &MediaPath, + device: DeviceId, + hlc: i64, +) -> Result { + let vol_str = volume.0.to_string(); + let old_str = old_path.as_str(); + let new_str = new_path.as_str(); + let dev_str = device.0.to_string(); + let now = now_iso(); + + // WHY BEGIN IMMEDIATE: the collision check + UPDATE/soft-delete + // sequence must serialize. The single writer actor already serializes + // commands, but IMMEDIATE is cheap and documents the intent. + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(Error::from)?; + + // Check whether an active row already exists at `new_path`. + let collision: Option = tx + .query_row( + "SELECT id FROM file_locations + WHERE volume_id = ?1 AND relative_path = ?2 AND deleted_at IS NULL", + rusqlite::params![vol_str, new_str], + |row| row.get(0), + ) + .optional() + .map_err(Error::from)?; + + let n = if collision.is_some() { + // WHY: destination wins. Soft-delete the source row so the + // invariant "1 active row per (vol, path)" holds. CRDT-friendly: + // no hard delete, deleted_at/updated_at/device_id/hlc all stamped. + tx.execute( + "UPDATE file_locations + SET deleted_at = ?1, updated_at = ?1, device_id = ?2, hlc = ?3 + WHERE volume_id = ?4 AND relative_path = ?5 AND deleted_at IS NULL", + rusqlite::params![now, dev_str, hlc, vol_str, old_str], + ) + .map_err(Error::from)? + } else { + tx.execute( + "UPDATE file_locations + SET relative_path = ?1, status = 'active', updated_at = ?2, + device_id = ?3, hlc = ?4 + WHERE volume_id = ?5 AND relative_path = ?6 AND deleted_at IS NULL", + rusqlite::params![new_str, now, dev_str, hlc, vol_str, old_str], + ) + .map_err(Error::from)? + }; + + tx.commit().map_err(Error::from)?; + + // WHY: at most 1 active row per (volume, path) by app-level invariant, + // so `n` is always 0 or 1. + u64::try_from(n).map_err(|_| CoreError::Internal(format!("rows_changed {n} is negative"))) +} + +/// Writer-side body for [`FileWriteCmd::MigrateSentinelRow`]. Lifted verbatim +/// from the pre-Batch-C `SqliteFileRepository::migrate_sentinel_row` impl with +/// `hlc = ?` bound on the UPDATE. +/// +/// WHY: scan in phase 1b wrote every `file_locations` row with +/// `volume_id = '00000000-0000-0000-0000-000000000000'` (the nil UUID). +/// Phase 1c resolves the real volume for each scan root. Rather than +/// a bulk UPDATE, we update one row at a time — scoped by `(relative_path, +/// sentinel volume_id, deleted_at IS NULL)` — immediately after the live +/// upsert confirms the path still exists on disk. +/// +/// Returns the number of rows updated (0 if no sentinel row existed). +fn migrate_sentinel_row_impl( + conn: &mut Connection, + path: &MediaPath, + real_volume: VolumeId, + device: DeviceId, + hlc: i64, +) -> Result { + // WHY BEGIN IMMEDIATE: pure UPDATE but IMMEDIATE avoids write-lock + // upgrade race. Consistent with all other write paths in this module. + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(Error::from)?; + + let now = now_iso(); + let vol_str = real_volume.0.to_string(); + let dev_str = device.0.to_string(); + let path_str = path.as_str(); + // WHY: nil UUID string literal is hard-coded here because this method + // is the *only* place we intentionally touch sentinel rows. Using a + // constant avoids importing VolumeId into a string constant but keeps + // the magic value visible and auditable. + let n = tx + .execute( + "UPDATE file_locations + SET volume_id = ?1, updated_at = ?2, device_id = ?3, hlc = ?4 + WHERE volume_id = '00000000-0000-0000-0000-000000000000' + AND relative_path = ?5 AND deleted_at IS NULL", + rusqlite::params![vol_str, now, dev_str, hlc, path_str], + ) + .map_err(Error::from)?; + + tx.commit().map_err(Error::from)?; + + // WHY: schema guarantees at most 1 sentinel row per path, so n is 0 or 1. + u64::try_from(n).map_err(|_| CoreError::Internal(format!("rows_changed {n} is negative"))) +} diff --git a/crates/db/src/writer/metadata.rs b/crates/db/src/writer/metadata.rs new file mode 100644 index 0000000..9516349 --- /dev/null +++ b/crates/db/src/writer/metadata.rs @@ -0,0 +1,332 @@ +//! Writer-side handler for [`crate::cmd::MetadataWriteCmd`]. +//! +//! Lifts the SQL bodies that previously lived inside +//! `impl MetadataRepository for SqliteMetadataRepository::{upsert_metadata, +//! update_thumbnail}` (pre-Batch-C) into writer-owned functions. The +//! writer thread holds the sole writable [`rusqlite::Connection`] +//! (spec §3.1); the adapter on the caller-side is now a thin send → +//! recv shim (see `crates/db/src/metadata_repo.rs`). +//! +//! # HLC semantics +//! +//! Each command computes `let hlc = Hlc::now().pack();` ONCE at the top +//! of [`handle`] and binds the same packed value to every `file_metadata` +//! row written by the command — one HLC value per user-visible logical +//! event (spec §3.7). Per V009: +//! +//! - `file_metadata.hlc` bumps on INSERT (new metadata row) and on the +//! extractor-driven UPDATE branch of [`crate::cmd::MetadataWriteCmd::UpsertMetadata`]. +//! - The `Unchanged` arm skips every write entirely (SELECT-only); +//! no `hlc` write happens and the prior value is preserved (same +//! logical event did not fire). +//! - [`crate::cmd::MetadataWriteCmd::UpdateThumbnail`] bumps `hlc` on +//! its UPDATE — the thumbnail flip is its own logical event, distinct +//! from extractor upserts. +//! +//! # Events +//! +//! After a successful COMMIT on `UpsertMetadata` (Inserted / Updated) +//! or `UpdateThumbnail` (rows > 0), the writer emits +//! [`perima_core::AppEvent::IndexInvalidated`] with +//! [`perima_core::InvalidationReason::MetadataChanged`] — the coarse +//! v1 signal that metadata-shaped query indexes (file detail panel, +//! thumbnail grid, capture-time sort) are stale. +//! +//! WHY skip emit on `Unchanged`: the Unchanged arm writes zero rows +//! and does not bump hlc — not a logical event per spec §3.3. + +use std::sync::Arc; + +use perima_core::{ + AppEvent, BlakeHash, CoreError, DeviceId, EventBus, Hlc, InvalidationReason, MediaMetadata, + UpsertOutcome, +}; +use rusqlite::{Connection, OptionalExtension}; + +use crate::cmd::MetadataWriteCmd; +use crate::errors::Error; + +/// Writer-side dispatch for [`MetadataWriteCmd`]. Consumes the command +/// (the reply channel lives inside each variant) and sends the result +/// back on the caller's reply channel. +/// +/// After successful writes that actually change state, this fn emits +/// [`AppEvent::IndexInvalidated`] with +/// [`InvalidationReason::MetadataChanged`] AFTER the COMMIT — see spec +/// §3.3. +#[allow(clippy::needless_pass_by_value)] +pub(super) fn handle(conn: &mut Connection, cmd: MetadataWriteCmd, bus: &Arc) { + // WHY one HLC per command (not per row): the "one HLC per + // user-visible logical event" invariant from spec §3.7. A single + // upsert_metadata may INSERT a new row OR UPDATE an existing one; + // both paths bind the same `hlc` value. The `Unchanged` arm skips + // every write — no `hlc` is consumed. + let hlc = Hlc::now().pack(); + + match cmd { + MetadataWriteCmd::UpsertMetadata { + record, + device, + reply, + } => { + let out = upsert_metadata_impl(conn, &record, device, hlc); + // WHY emit gated on Inserted | Updated: the `Unchanged` + // arm writes zero rows and does not bump hlc. + if matches!(&out, Ok(o) if !matches!(o, UpsertOutcome::Unchanged)) { + emit_metadata_changed(bus, "upsert_metadata"); + } + if reply.send(out).is_err() { + // WHY debug (not warn): caller dropped its reply + // handle — e.g. CLI aborted mid-command. The write + // already committed; nothing actionable. + tracing::debug!("metadata upsert_metadata reply channel closed before send"); + } + } + MetadataWriteCmd::UpdateThumbnail { + hash, + path, + status, + device, + reply, + } => { + let out = update_thumbnail_impl(conn, &hash, path.as_deref(), &status, device, hlc); + // WHY emit gated on rows > 0: a thumbnail flip against a + // missing metadata row writes zero rows. + if matches!(&out, Ok(rows) if *rows > 0) { + emit_metadata_changed(bus, "update_thumbnail"); + } + if reply.send(out).is_err() { + tracing::debug!("metadata update_thumbnail reply channel closed before send"); + } + } + } +} + +/// Emit `IndexInvalidated::MetadataChanged` and log on emit failure. +/// +/// `who` identifies the calling sub-command for log scoping. Failed +/// emits log-only — the COMMIT already landed and the caller already +/// got (or is about to get) its reply. +fn emit_metadata_changed(bus: &Arc, who: &'static str) { + if let Err(e) = bus.emit(&AppEvent::IndexInvalidated { + reason: InvalidationReason::MetadataChanged, + }) { + tracing::warn!(?e, who, "post-commit emit failed: MetadataChanged"); + } +} + +/// ISO-8601 UTC timestamp used for `updated_at` / `extracted_at`. +/// Matches the pre-Batch-C adapter's `now_iso` helper. +fn now_iso() -> String { + chrono::Utc::now().to_rfc3339() +} + +/// Convert `Option` to `Option` for binding as `INTEGER`. +/// +/// WHY: `rusqlite`'s `ToSql` impl does not cover `u64` (`SQLite` +/// integers are signed 64-bit). Values originating from media +/// containers (duration in ms) fit comfortably in `i64` on any +/// real-world asset; we propagate overflow as `Internal` rather than +/// truncating. +fn u64_opt_to_i64(v: Option) -> Result, CoreError> { + v.map(|raw| { + i64::try_from(raw).map_err(|_| { + CoreError::Internal(format!("duration_ms {raw} overflows SQLite INTEGER (i64)")) + }) + }) + .transpose() +} + +/// Writer-side body for [`MetadataWriteCmd::UpsertMetadata`]. Lifted +/// verbatim from the pre-Batch-C `SqliteMetadataRepository::upsert_metadata` +/// with `hlc = ?` bound on both the INSERT path and the extractor-driven +/// UPDATE path. +/// +/// # Thumbnail-column preservation +/// +/// Neither branch touches `thumbnail_path` or `thumbnail_status` — the +/// worker's [`MetadataWriteCmd::UpdateThumbnail`] is the sole writer of +/// those columns. Preserving whatever state the worker has already +/// written across an Updated upsert is the invariant pinned by the +/// `upsert_metadata_preserves_thumbnail_state` regression +/// (utof/perima#15 HIGH #4). On INSERT, `thumbnail_status` is seeded +/// with the literal `'pending'` so the partial index +/// `idx_file_metadata_thumbnail_pending` stays populated for every new +/// row (utof/perima#15 HIGH #3 + V004 backfill). +fn upsert_metadata_impl( + conn: &mut Connection, + meta: &MediaMetadata, + device: DeviceId, + hlc: i64, +) -> Result { + // WHY BEGIN IMMEDIATE: historical rationale (pre-Batch-C) guarded + // against two adapter handles racing on SELECT-then-INSERT. The + // single writer actor now guarantees serialization, BUT retaining + // IMMEDIATE is cheap and documents the "only one write tx at a + // time" expectation at the SQL boundary. + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(Error::from)?; + + let hash_hex = meta.hash.to_hex(); + let now = now_iso(); + let dev_str = device.0.to_string(); + let duration_ms_i64 = u64_opt_to_i64(meta.duration_ms)?; + + // Mirror `SqliteFileRepository::upsert_file`'s SELECT-then- + // INSERT/UPDATE on the content-addressed PK (blake3_hash). We + // fetch the existing row's device_id + mime_type for a cheap + // equivalence proxy to classify Unchanged vs Updated. + let existing: Option<(String, Option)> = tx + .query_row( + "SELECT device_id, mime_type FROM file_metadata + WHERE blake3_hash = ?1 AND deleted_at IS NULL", + [&hash_hex], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(Error::from)?; + + let outcome = match existing { + None => { + // WHY thumbnail_path / thumbnail_status NOT bound from + // `meta`: extractors always produce `None` for these + // fields. The queue worker writes them via the dedicated + // `UpdateThumbnail` variant after thumbnail generation + // completes. A subsequent Updated upsert (triggered by + // a mime_type flip on the same hash) would otherwise + // clobber the worker's state back to NULL, silently + // losing the thumbnail association. See utof/perima#15 + // HIGH #4 for the regression this prevents. + // + // WHY `thumbnail_status` literal-default 'pending' on + // INSERT: V004 backfills the NULL rows left by v0.4.0– + // v0.4.1, and future INSERTs need to produce 'pending' + // on the same path so the + // `idx_file_metadata_thumbnail_pending` partial index + // stays populated. The literal lives in the SQL, not + // in `MediaMetadata`, because the UPDATE branch of + // this upsert deliberately never touches thumbnail + // columns (Task 2 decoupling). See utof/perima#15 + // HIGH #3. + tx.execute( + "INSERT INTO file_metadata + (blake3_hash, width, height, duration_ms, captured_at, + camera_make, camera_model, codec, bitrate_bps, mime_type, + thumbnail_status, + extracted_at, updated_at, device_id, hlc) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, + 'pending', + ?11, ?11, ?12, ?13)", + rusqlite::params![ + hash_hex, + meta.width, + meta.height, + duration_ms_i64, + meta.captured_at, + meta.camera_make, + meta.camera_model, + meta.codec, + meta.bitrate_bps, + meta.mime_type, + now, + dev_str, + hlc, + ], + ) + .map_err(Error::from)?; + UpsertOutcome::Inserted + } + Some((existing_dev, existing_mime)) + if existing_dev == dev_str && existing_mime == meta.mime_type => + { + // WHY cheap equality proxy: comparing every Option field + // would bloat this method and still miss changes hidden + // in (say) camera_model alone. mime_type + device_id is + // the coarsest check that classifies "new extraction + // run" vs "repeat call with identical inputs". v0.4.0 + // accepts occasional false-Updated over false-Unchanged + // as the safe default. + // + // WHY no hlc write on Unchanged: same logical event did + // not fire — preserving the prior hlc matches the + // tag/volume upsert semantics (spec §3.7). + UpsertOutcome::Unchanged + } + Some(_) => { + // WHY UPDATE omits thumbnail_path / thumbnail_status: + // same rationale as the INSERT branch above. The + // worker's `UpdateThumbnail` command is the sole writer + // of those columns. + tx.execute( + "UPDATE file_metadata + SET width = ?2, height = ?3, duration_ms = ?4, + captured_at = ?5, camera_make = ?6, camera_model = ?7, + codec = ?8, bitrate_bps = ?9, mime_type = ?10, + updated_at = ?11, device_id = ?12, hlc = ?13 + WHERE blake3_hash = ?1", + rusqlite::params![ + hash_hex, + meta.width, + meta.height, + duration_ms_i64, + meta.captured_at, + meta.camera_make, + meta.camera_model, + meta.codec, + meta.bitrate_bps, + meta.mime_type, + now, + dev_str, + hlc, + ], + ) + .map_err(Error::from)?; + UpsertOutcome::Updated + } + }; + + tx.commit().map_err(Error::from)?; + Ok(outcome) +} + +/// Writer-side body for [`MetadataWriteCmd::UpdateThumbnail`]. Lifted +/// verbatim from the pre-Batch-C `SqliteMetadataRepository::update_thumbnail` +/// with `hlc = ?` bound on the UPDATE. +/// +/// Returns the number of rows updated (0 if no metadata row exists +/// for `hash`; 1 otherwise). +fn update_thumbnail_impl( + conn: &mut Connection, + hash: &BlakeHash, + path: Option<&str>, + status: &str, + device: DeviceId, + hlc: i64, +) -> Result { + // WHY BEGIN IMMEDIATE: the UPDATE is a pure single-statement + // mutation, but IMMEDIATE avoids a write-lock upgrade race that + // DEFERRED can trigger under WAL. Consistent with every other + // write path in this crate. + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(Error::from)?; + + let hash_hex = hash.to_hex(); + let now = now_iso(); + let dev_str = device.0.to_string(); + let rows_changed = tx + .execute( + "UPDATE file_metadata + SET thumbnail_path = ?1, thumbnail_status = ?2, + updated_at = ?3, device_id = ?4, hlc = ?5 + WHERE blake3_hash = ?6 AND deleted_at IS NULL", + rusqlite::params![path, status, now, dev_str, hlc, hash_hex], + ) + .map_err(Error::from)?; + + tx.commit().map_err(Error::from)?; + + u64::try_from(rows_changed) + .map_err(|_| CoreError::Internal(format!("rows_changed {rows_changed} is negative"))) +} diff --git a/crates/db/src/writer/mod.rs b/crates/db/src/writer/mod.rs new file mode 100644 index 0000000..55cba1e --- /dev/null +++ b/crates/db/src/writer/mod.rs @@ -0,0 +1,257 @@ +//! Single-writer `SQLite` actor. Owns the sole writable [`Connection`] +//! for its lifetime on a dedicated OS thread. +//! +//! Writer lifecycle: +//! 1. [`SqliteWriter::start`] opens a [`Connection`], runs Refinery +//! migrations synchronously, then spawns the writer thread moving +//! the connection in. Migrations happen exactly once, before the +//! thread loops — spec §3.6 invariant. +//! 2. Adapters send [`crate::WriteCmd`] variants over the +//! [`flume::Sender`] returned in [`SqliteWriterHandle::sender`]. +//! Each handler pattern (spec §3.3): +//! +//! ```text +//! match handler_impl(conn, sub_cmd) { +//! Ok((out, events)) => { +//! for ev in &events { +//! if let Err(e) = bus.emit(ev) { +//! tracing::warn!(?e, ?ev, "post-commit emit failed"); +//! } +//! } +//! if reply.send(Ok(out)).is_err() { +//! tracing::debug!("reply channel closed before send"); +//! } +//! } +//! Err(e) => { +//! if reply.send(Err(e)).is_err() { +//! tracing::debug!("reply channel closed before send (error path)"); +//! } +//! } +//! } +//! ``` +//! 3. Dropping the last [`flume::Sender`] closes the channel; +//! the writer observes `recv() == Err(Disconnected)` and returns. +//! +//! See `docs/superpowers/specs/2026-04-21-arch-audit-batch-C-connection-model-design.md`. + +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; + +use flume::{Receiver, Sender}; +use perima_core::{CoreError, EventBus}; +use rusqlite::Connection; + +use crate::cmd::WriteCmd; +use crate::connection::open_and_migrate; + +mod file; +mod metadata; +mod search; +mod tag; +mod volume; + +/// Handle returned by [`SqliteWriter::start`]. +/// +/// Clone [`Self::sender`] to inject into repo-adapter constructors. +/// The underlying [`JoinHandle`] is single-consumer, wrapped in +/// `Arc>>` so the handle itself can [`Clone`] without +/// duplicating join rights. Only the explicit [`Self::join`] call (or +/// the final drop of the last writer-owned handle) reaps the thread. +#[derive(Clone)] +pub struct SqliteWriterHandle { + sender: Sender, + // WHY `Option` + `Arc>`: the `JoinHandle<()>` is + // single-consumer. `Arc` lets `SqliteWriterHandle` be `Clone`; + // `Mutex` serializes any race between a `.join()` caller and a + // final-drop caller. `.take()` moves the handle out on first + // consumer; subsequent callers see `None` and silently noop. + join: Arc>>>, +} + +impl std::fmt::Debug for SqliteWriterHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // WHY custom Debug: `JoinHandle<()>` is not `Debug`; we print + // a placeholder tag + the current sender count for observability. + let joined = self + .join + .lock() + .ok() + .map_or("poisoned", |g| if g.is_some() { "live" } else { "reaped" }); + f.debug_struct("SqliteWriterHandle") + .field("sender_senders", &self.sender.sender_count()) + .field("join", &joined) + .finish() + } +} + +impl SqliteWriterHandle { + /// Clone the write-command sender for injection into repo adapters. + #[must_use] + pub fn sender(&self) -> Sender { + self.sender.clone() + } + + /// Wait for the writer thread to finish. + /// + /// Consumes the handle, dropping *this* handle's sender BEFORE + /// joining. If no other clones of the handle still exist, the + /// channel closes and the writer loop exits; if clones remain, + /// this call blocks until the last clone drops its sender. + /// + /// WHY consuming self: a `&self` variant would deadlock — the + /// handle itself holds the sender, and `JoinHandle::join` can't + /// return until the writer loop exits, which requires the sender + /// refcount to hit zero. Taking `self` lets us `drop(self.sender)` + /// inside the function body before joining. + pub fn join(self) { + // Destructure to drop the sender BEFORE joining the thread. + let Self { sender, join } = self; + drop(sender); + let h = join.lock().ok().and_then(|mut g| g.take()); + if let Some(h) = h { + // WHY swallow: the writer loop is infallible; a panicked + // writer thread would surface as `Err(Any)` here, but the + // handle is an advisory shutdown primitive — we can't do + // anything useful with the panic payload at teardown. + let _ = h.join(); + } + } +} + +/// `SQLite` writer actor. Owns the sole writable [`Connection`]. +#[derive(Debug)] +pub struct SqliteWriter; + +impl SqliteWriter { + /// Open `db_path`, run migrations synchronously on the connection + /// that becomes the writer, then spawn the writer thread and return + /// the handle. + /// + /// `bus` is the [`EventBus`] the writer will `emit` domain events + /// through AFTER each successful `COMMIT`. Per Batch B standing + /// constraint the writer does NOT construct a + /// `CompositeEventBus` — it consumes one passed from + /// `AppContainer::new`. + /// + /// # Errors + /// Returns [`CoreError::Internal`] on migration failure or thread + /// spawn failure. + pub fn start(db_path: &Path, bus: Arc) -> Result { + // WHY: migrations run on the connection that becomes the writer, + // synchronously, BEFORE the thread spawns. Read pool opens + // afterwards against a fully-migrated schema (spec §3.6). + let conn = + open_and_migrate(db_path).map_err(|e| CoreError::Internal(format!("migrate: {e}")))?; + spawn_writer(conn, bus) + } + + /// Test-only in-memory writer. + /// + /// Runs migrations on a fresh `:memory:` connection, then spawns the + /// writer thread. Used by `#[cfg(test)]` fixtures throughout the + /// workspace. + /// + /// # Errors + /// Returns [`CoreError::Internal`] on migration failure or thread + /// spawn failure. + #[cfg(test)] + pub(crate) fn start_in_memory(bus: Arc) -> Result { + mod embedded { + use refinery::embed_migrations; + embed_migrations!("migrations"); + } + + let mut conn = Connection::open_in_memory() + .map_err(|e| CoreError::Internal(format!("open_in_memory: {e}")))?; + embedded::migrations::runner() + .run(&mut conn) + .map_err(|e| CoreError::Internal(format!("migrate in-memory: {e}")))?; + spawn_writer(conn, bus) + } +} + +/// Spawn the writer thread, consuming `conn`. Shared between +/// [`SqliteWriter::start`] and the test-only in-memory variant. +fn spawn_writer(conn: Connection, bus: Arc) -> Result { + let (sender, receiver) = flume::unbounded::(); + let handle = thread::Builder::new() + .name("perima-sqlite-writer".into()) + .spawn(move || run_writer_loop(conn, receiver, bus)) + .map_err(|e| CoreError::Internal(format!("writer thread spawn: {e}")))?; + + Ok(SqliteWriterHandle { + sender, + join: Arc::new(Mutex::new(Some(handle))), + }) +} + +// WHY allow needless_pass_by_value: `bus` and `receiver` are moved into +// the writer thread and live for its lifetime; passing by reference would +// force a shorter lifetime bound than `'static`, which `thread::spawn` +// requires. Same applies to `spawn_writer` below. +#[allow(clippy::needless_pass_by_value)] +fn run_writer_loop(mut conn: Connection, receiver: Receiver, bus: Arc) { + tracing::debug!("sqlite writer actor started"); + while let Ok(cmd) = receiver.recv() { + dispatch(&mut conn, cmd, &bus); + } + tracing::debug!("sqlite writer actor exiting (channel disconnected)"); +} + +fn dispatch(conn: &mut Connection, cmd: WriteCmd, bus: &Arc) { + // WHY the match-level dispatch: each per-repo handler owns its own + // commit + event-emit pattern. The shared shape each handler must + // follow (spec §3.3) is: + // + // match handler_impl(conn, sub_cmd) { + // Ok((out, events)) => { + // for ev in &events { + // if let Err(e) = bus.emit(ev) { + // tracing::warn!(?e, ?ev, "post-commit emit failed"); + // } + // } + // if reply.send(Ok(out)).is_err() { + // tracing::debug!("reply closed"); + // } + // } + // Err(e) => { + // if reply.send(Err(e)).is_err() { + // tracing::debug!("reply closed (error path)"); + // } + // } + // } + // + // Tasks 2-6 populate each sub-enum's handler following this shape. + match cmd { + WriteCmd::Volume(c) => volume::handle(conn, c, bus), + WriteCmd::Tag(c) => tag::handle(conn, c, bus), + WriteCmd::Metadata(c) => metadata::handle(conn, c, bus), + WriteCmd::File(c) => handle_file(conn, c, bus), + WriteCmd::Search(c) => search::handle(conn, c, bus), + } +} + +fn handle_file(conn: &mut Connection, cmd: crate::cmd::FileWriteCmd, bus: &Arc) { + file::handle(conn, cmd, bus); +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use perima_core::EventBus; + + use super::SqliteWriter; + use crate::test_utils::NoopBus; + + #[test] + fn writer_spawns_and_shuts_down_cleanly() { + let bus: Arc = Arc::new(NoopBus); + let handle = SqliteWriter::start_in_memory(bus).expect("spawn writer"); + // Dropping the handle drops its internal `sender`; because no + // other senders exist (we haven't cloned any), the writer loop + // observes `recv() == Err(Disconnected)` and returns. + handle.join(); + } +} diff --git a/crates/db/src/writer/search.rs b/crates/db/src/writer/search.rs new file mode 100644 index 0000000..e7c92fb --- /dev/null +++ b/crates/db/src/writer/search.rs @@ -0,0 +1,135 @@ +//! Writer-side handler for [`crate::cmd::SearchWriteCmd`]. +//! +//! # HLC semantics +//! +//! Per spec §3.7 HLC-bearing table list: `files`, `file_locations`, +//! `file_metadata`, `tags`, `file_tags`, `volumes`. The FTS5 virtual +//! table backing `search` is NOT on that list — `rebuild` is a +//! dump-and-reseed of the FTS index from source rows that carry their +//! own `hlc`. The source rows are untouched by `Rebuild`. No `hlc` +//! binding in this module. +//! +//! # Events +//! +//! After a successful COMMIT on `Rebuild`, the writer emits +//! [`perima_core::AppEvent::IndexInvalidated`] with +//! [`perima_core::InvalidationReason::SearchIndexRebuilt`] — the v1 +//! signal that the FTS5 search index has been wiped and reseeded; +//! any cached search-result list is stale. +//! +//! WHY no `now_iso` / `Hlc` import: the FTS5 virtual table itself +//! carries no `hlc` column (it is a derived index, not a source-of-truth +//! row). Writing `hlc` here would have no effect on CRDT semantics. + +use std::sync::Arc; + +use perima_core::{AppEvent, CoreError, EventBus, InvalidationReason}; +use rusqlite::Connection; + +use crate::cmd::SearchWriteCmd; +use crate::errors::Error; + +/// Writer-side dispatch for [`SearchWriteCmd`]. Consumes the command +/// (the reply channel lives inside each variant) and sends the result +/// back on the caller's reply channel. +/// +/// After a successful COMMIT on `Rebuild`, this fn emits +/// [`AppEvent::IndexInvalidated`] with +/// [`InvalidationReason::SearchIndexRebuilt`] AFTER the COMMIT — see +/// spec §3.3. +#[allow(clippy::needless_pass_by_value)] +pub(super) fn handle(conn: &mut Connection, cmd: SearchWriteCmd, bus: &Arc) { + match cmd { + SearchWriteCmd::Rebuild { reply } => { + let out = rebuild_impl(conn); + // WHY unconditional emit on Ok: rebuild ALWAYS wipes + + // reseeds the FTS index. Even if the source rows were + // empty, the index state changed (cleared); a cached + // search result list with stale row metadata is + // invalidated. + if out.is_ok() + && let Err(e) = bus.emit(&AppEvent::IndexInvalidated { + reason: InvalidationReason::SearchIndexRebuilt, + }) + { + tracing::warn!(?e, "post-commit emit failed: SearchIndexRebuilt"); + } + if reply.send(out).is_err() { + // WHY debug (not warn): caller dropped its reply handle — + // e.g. CLI aborted mid-command. The rebuild already ran; + // nothing actionable here. + tracing::debug!("search rebuild reply channel closed before send"); + } + } + } +} + +/// Writer-side body for [`SearchWriteCmd::Rebuild`]. Lifted verbatim +/// from the pre-Batch-C `SqliteSearchRepository::rebuild` with no `hlc` +/// binding — per spec §3.7, the FTS5 virtual table is not on the +/// HLC-bearing table list. +/// +/// Semantics: open an IMMEDIATE transaction, DELETE all rows from +/// `search_content`, then repopulate from the live join of +/// `file_locations` + `file_metadata` + `file_tags`. The FTS5 index +/// (`search_index`) is kept in sync via `SQLite` AFTER-INSERT / AFTER-DELETE +/// triggers on `search_content`; no explicit +/// `INSERT INTO search_index(search_index) VALUES('rebuild')` needed. +fn rebuild_impl(conn: &mut Connection) -> Result<(), CoreError> { + // WHY BEGIN IMMEDIATE: consistent with all other writer handlers + // (see tag.rs, metadata.rs, file.rs rationale). Prevents a + // write-lock upgrade race under WAL that DEFERRED can trigger. + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(Error::from)?; + + // V007 rebuild: wipe search_content, then repopulate from joined + // live state. The search_content AFTER-INSERT/DELETE triggers keep + // search_index in sync row-by-row — no explicit FTS5 'rebuild' + // needed here. + // + // WHY not 'INSERT INTO search_index(search_index) VALUES('rebuild')': + // that primitive is an external-content resync from search_content, + // but the DELETE + INSERT path above already drives FTS via triggers. + // Calling 'rebuild' would be redundant (and defensive for a case + // that doesn't exist here: search_content-out-of-sync-with-index). + tx.execute_batch("DELETE FROM search_content;") + .map_err(Error::from)?; + + // Populate search_content: one representative location per hash, + // joined with metadata + tags. Mirrors V007 migration bulk-insert. + // WHY filename = relative_path: SQLite has no built-in REVERSE() for + // basename extraction; the unicode61 tokenizer splits on '/' and '.' + // so basenames are discoverable via token match on relative_path. + tx.execute_batch( + "INSERT INTO search_content + (blake3_hash, filename, relative_path, mime_type, camera_model, captured_at, tags) + SELECT fl.blake3_hash, + fl.relative_path, + fl.relative_path, + COALESCE(m.mime_type, ''), + COALESCE(m.camera_model, ''), + COALESCE(m.captured_at, ''), + COALESCE(( + SELECT GROUP_CONCAT(t.name, ' ') + FROM file_tags ft + JOIN tags t ON t.id = ft.tag_id + WHERE ft.blake3_hash = fl.blake3_hash + AND ft.deleted_at IS NULL + AND t.deleted_at IS NULL + ), '') + FROM file_locations fl + LEFT JOIN file_metadata m ON m.blake3_hash = fl.blake3_hash + AND m.deleted_at IS NULL + WHERE fl.deleted_at IS NULL + AND fl.id = ( + SELECT id FROM file_locations + WHERE blake3_hash = fl.blake3_hash AND deleted_at IS NULL + ORDER BY first_seen ASC, id ASC LIMIT 1 + );", + ) + .map_err(Error::from)?; + + tx.commit().map_err(Error::from)?; + Ok(()) +} diff --git a/crates/db/src/writer/tag.rs b/crates/db/src/writer/tag.rs new file mode 100644 index 0000000..47856fb --- /dev/null +++ b/crates/db/src/writer/tag.rs @@ -0,0 +1,320 @@ +//! Writer-side handler for [`crate::cmd::TagWriteCmd`]. +//! +//! Lifts the SQL bodies that previously lived inside +//! `impl TagRepository for SqliteTagRepository::{upsert_tag, delete_tag, +//! attach, detach}` (pre-Batch-C) into writer-owned functions. The +//! writer thread holds the sole writable [`rusqlite::Connection`] +//! (spec §3.1); the adapter on the caller-side is now a thin send → +//! recv shim (see `crates/db/src/tag_repo.rs`). +//! +//! # HLC semantics +//! +//! Each command computes `let hlc = Hlc::now().pack();` ONCE at the top +//! of [`handle`] and binds the same packed value to every `tags` / +//! `file_tags` row written by the command — one HLC value per +//! user-visible logical event (spec §3.7). Per V009: +//! +//! - `tags.hlc` bumps on INSERT (new tag) and on UPDATE +//! (soft-delete via [`crate::cmd::TagWriteCmd::DeleteTag`]). +//! - `file_tags.hlc` bumps on INSERT (new attach) and on UPDATE +//! (soft-delete via [`crate::cmd::TagWriteCmd::Detach`]). +//! - The idempotent `Attach` arm skips the INSERT entirely when an +//! active row already exists; no `hlc` write happens and the prior +//! value is preserved (same logical event did not fire). +//! +//! # Events +//! +//! After a successful COMMIT on `Attach` / `Detach`, the writer emits +//! [`perima_core::AppEvent::IndexInvalidated`] with +//! [`perima_core::InvalidationReason::TagsChanged`] — the coarse v1 +//! signal that tag-shaped query indexes (frontend tag list, file→tags +//! join) are stale. `UpsertTag` / `DeleteTag` also emit `TagsChanged`: +//! the tag list itself is the invalidated index. +//! +//! WHY skip emit on no-op `Attach`: re-attaching an already-active +//! `(hash, tag_id)` pair writes zero rows and does not bump `hlc` (see +//! `attach_impl` below). No row written → no logical event happened → +//! no `AppEvent` per spec §3.3 ("publish events AFTER COMMIT" — but +//! only when a COMMIT actually changed state). + +use std::sync::Arc; + +use perima_core::{ + AppEvent, BlakeHash, CoreError, DeviceId, EventBus, Hlc, InvalidationReason, Tag, +}; +use rusqlite::{Connection, OptionalExtension}; +use uuid::Uuid; + +use crate::cmd::TagWriteCmd; +use crate::errors::Error; + +/// Writer-side dispatch for [`TagWriteCmd`]. Consumes the command +/// (the reply channel lives inside each variant) and sends the result +/// back on the caller's reply channel. +/// +/// After successful writes that actually change state (i.e. did not +/// land on an idempotent no-op path), this fn emits +/// [`AppEvent::IndexInvalidated`] with +/// [`InvalidationReason::TagsChanged`] AFTER the COMMIT — see spec §3.3. +#[allow(clippy::needless_pass_by_value)] +pub(super) fn handle(conn: &mut Connection, cmd: TagWriteCmd, bus: &Arc) { + // WHY one HLC per command (not per row): the "one HLC per + // user-visible logical event" invariant from spec §3.7. A single + // upsert_tag may INSERT a new row OR UPDATE an existing one; both + // paths bind the same `hlc` value. + let hlc = Hlc::now().pack(); + + match cmd { + TagWriteCmd::UpsertTag { + name, + device, + reply, + } => { + let out = upsert_tag_impl(conn, &name, device, hlc); + if reply.send(out).is_err() { + // WHY debug (not warn): caller dropped its reply + // handle — e.g. CLI aborted mid-command. The write + // already committed; nothing actionable. + tracing::debug!("tag upsert_tag reply channel closed before send"); + } + } + TagWriteCmd::DeleteTag { + tag_id, + device, + reply, + } => { + let out = delete_tag_impl(conn, tag_id, device, hlc); + if reply.send(out).is_err() { + tracing::debug!("tag delete_tag reply channel closed before send"); + } + } + TagWriteCmd::Attach { + hash, + tag_id, + device, + reply, + } => { + let out = attach_impl(conn, &hash, tag_id, device, hlc); + // WHY emit gated on rows_changed > 0: the idempotent no-op + // path (already-attached pair) writes zero rows, does not + // bump hlc, and is NOT a logical event per spec §3.3. + if let Ok(rows) = &out + && *rows > 0 + && let Err(e) = bus.emit(&AppEvent::IndexInvalidated { + reason: InvalidationReason::TagsChanged, + }) + { + tracing::warn!(?e, "post-commit emit failed: TagsChanged (attach)"); + } + if reply.send(out).is_err() { + tracing::debug!("tag attach reply channel closed before send"); + } + } + TagWriteCmd::Detach { + hash, + tag_id, + device, + reply, + } => { + let out = detach_impl(conn, &hash, tag_id, device, hlc); + // WHY emit gated on rows_changed > 0: detaching a pair + // that's already inactive writes zero rows and is not a + // logical event (spec §3.3). + if let Ok(rows) = &out + && *rows > 0 + && let Err(e) = bus.emit(&AppEvent::IndexInvalidated { + reason: InvalidationReason::TagsChanged, + }) + { + tracing::warn!(?e, "post-commit emit failed: TagsChanged (detach)"); + } + if reply.send(out).is_err() { + tracing::debug!("tag detach reply channel closed before send"); + } + } + } +} + +/// ISO-8601 UTC timestamp used for `updated_at` / `first_seen` / +/// `deleted_at`. Matches the pre-Batch-C adapter's `now_iso` helper. +fn now_iso() -> String { + chrono::Utc::now().to_rfc3339() +} + +/// Writer-side body for [`TagWriteCmd::UpsertTag`]. Lifted verbatim +/// from the pre-Batch-C `SqliteTagRepository::upsert_tag` with `hlc = ?` +/// bound on the INSERT path. +/// +/// Name normalization happens adapter-side; this function receives the +/// already-normalized `name` and does not re-validate. +fn upsert_tag_impl( + conn: &mut Connection, + name: &str, + device: DeviceId, + hlc: i64, +) -> Result { + // WHY BEGIN IMMEDIATE: historical rationale (pre-Batch-C) guarded + // against two adapter handles racing on SELECT-then-INSERT. The + // single writer actor now guarantees serialization, BUT retaining + // IMMEDIATE is cheap and documents the "only one write tx at a + // time" expectation at the SQL boundary. + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(Error::from)?; + + let existing: Option<(String, String)> = tx + .query_row( + "SELECT id, first_seen FROM tags WHERE name = ?1 AND deleted_at IS NULL", + [name], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(Error::from)?; + + let tag = if let Some((id_str, first_seen)) = existing { + let id = Uuid::parse_str(&id_str) + .map_err(|e| CoreError::Internal(format!("invalid uuid in db: {e}")))?; + Tag { + id, + name: name.to_owned(), + first_seen, + } + } else { + let id = Uuid::now_v7(); + let now = now_iso(); + let dev_str = device.0.to_string(); + tx.execute( + "INSERT INTO tags (id, name, first_seen, updated_at, device_id, hlc) + VALUES (?1, ?2, ?3, ?3, ?4, ?5)", + rusqlite::params![id.to_string(), name, now, dev_str, hlc], + ) + .map_err(Error::from)?; + Tag { + id, + name: name.to_owned(), + first_seen: now, + } + }; + + tx.commit().map_err(Error::from)?; + Ok(tag) +} + +/// Writer-side body for [`TagWriteCmd::DeleteTag`]. Lifted verbatim +/// from the pre-Batch-C `SqliteTagRepository::delete_tag` with `hlc = ?` +/// bound on the soft-delete UPDATE. +fn delete_tag_impl( + conn: &mut Connection, + tag_id: Uuid, + device: DeviceId, + hlc: i64, +) -> Result { + // WHY BEGIN IMMEDIATE: delete_tag is a pure UPDATE, but + // IMMEDIATE avoids a write-lock upgrade race that DEFERRED can + // trigger under WAL. Consistent with all other write paths. + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(Error::from)?; + + let now = now_iso(); + let dev_str = device.0.to_string(); + let rows_changed = tx + .execute( + "UPDATE tags SET deleted_at = ?1, updated_at = ?1, device_id = ?2, hlc = ?3 + WHERE id = ?4 AND deleted_at IS NULL", + rusqlite::params![now, dev_str, hlc, tag_id.to_string()], + ) + .map_err(Error::from)?; + + tx.commit().map_err(Error::from)?; + + u64::try_from(rows_changed) + .map_err(|_| CoreError::Internal(format!("rows_changed {rows_changed} is negative"))) +} + +/// Writer-side body for [`TagWriteCmd::Attach`]. Lifted verbatim from +/// the pre-Batch-C `SqliteTagRepository::attach` with `hlc = ?` bound +/// on the INSERT path. +fn attach_impl( + conn: &mut Connection, + hash: &BlakeHash, + tag_id: Uuid, + device: DeviceId, + hlc: i64, +) -> Result { + let hash_hex = hash.to_hex(); + let tag_id_str = tag_id.to_string(); + + // WHY BEGIN IMMEDIATE: SELECT-then-INSERT must be atomic to + // prevent duplicate active (hash, tag_id) rows under concurrency. + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(Error::from)?; + + let existing: Option = tx + .query_row( + "SELECT id FROM file_tags + WHERE blake3_hash = ?1 AND tag_id = ?2 AND deleted_at IS NULL", + [&hash_hex, &tag_id_str], + |row| row.get(0), + ) + .optional() + .map_err(Error::from)?; + + let rows_changed = if existing.is_none() { + let id = Uuid::now_v7(); + let now = now_iso(); + let dev_str = device.0.to_string(); + tx.execute( + "INSERT INTO file_tags + (id, blake3_hash, tag_id, first_seen, updated_at, device_id, hlc) + VALUES (?1, ?2, ?3, ?4, ?4, ?5, ?6)", + rusqlite::params![id.to_string(), hash_hex, tag_id_str, now, dev_str, hlc], + ) + .map_err(Error::from)? + } else { + 0 + }; + + tx.commit().map_err(Error::from)?; + + u64::try_from(rows_changed) + .map_err(|_| CoreError::Internal(format!("rows_changed {rows_changed} is negative"))) +} + +/// Writer-side body for [`TagWriteCmd::Detach`]. Lifted verbatim from +/// the pre-Batch-C `SqliteTagRepository::detach` with `hlc = ?` bound +/// on the soft-delete UPDATE. +fn detach_impl( + conn: &mut Connection, + hash: &BlakeHash, + tag_id: Uuid, + device: DeviceId, + hlc: i64, +) -> Result { + let hash_hex = hash.to_hex(); + let tag_id_str = tag_id.to_string(); + + // WHY BEGIN IMMEDIATE: detach is a pure UPDATE (no preceding + // SELECT), but IMMEDIATE avoids a write-lock upgrade race that + // DEFERRED can trigger under WAL. Consistent with all other + // write paths in this repo. + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(Error::from)?; + + let now = now_iso(); + let dev_str = device.0.to_string(); + let rows_changed = tx + .execute( + "UPDATE file_tags SET deleted_at = ?1, updated_at = ?1, device_id = ?2, hlc = ?3 + WHERE blake3_hash = ?4 AND tag_id = ?5 AND deleted_at IS NULL", + rusqlite::params![now, dev_str, hlc, hash_hex, tag_id_str], + ) + .map_err(Error::from)?; + + tx.commit().map_err(Error::from)?; + + u64::try_from(rows_changed) + .map_err(|_| CoreError::Internal(format!("rows_changed {rows_changed} is negative"))) +} diff --git a/crates/db/src/writer/volume.rs b/crates/db/src/writer/volume.rs new file mode 100644 index 0000000..a116504 --- /dev/null +++ b/crates/db/src/writer/volume.rs @@ -0,0 +1,302 @@ +//! Writer-side handler for [`crate::cmd::VolumeWriteCmd`]. +//! +//! Lifts the SQL bodies that previously lived inside +//! `impl VolumeRepository for SqliteVolumeRepository::{find_or_create, +//! record_mount}` (pre-Batch-C) into writer-owned functions. The +//! writer thread holds the sole writable [`rusqlite::Connection`] +//! (spec §3.1); the adapter on the caller-side is now a thin send → +//! recv shim (see `crates/db/src/volume_repo.rs`). +//! +//! # HLC semantics +//! +//! Each command computes `let hlc = Hlc::now().pack();` ONCE and binds +//! to every `volumes` row written. `volume_mounts` has no `hlc` column +//! per V009 (device-local rows do not participate in CRDT sync; Batch +//! A §5 — see `crates/db/migrations/V009__hlc_columns.sql`). One HLC +//! value per user-visible logical event (spec §3.7). +//! +//! # Events +//! +//! Volume writes do NOT emit any [`perima_core::AppEvent`] in v1. +//! +//! WHY no emit: volume writes don't invalidate any v1 query index. +//! The volumes UI reads volumes directly via +//! [`perima_core::VolumeRepository::list`], not through a cached/ +//! invalidated query layer. The four `InvalidationReason` variants +//! today (`TagsChanged`, `FilesChanged`, `MetadataChanged`, +//! `SearchIndexRebuilt`) intentionally exclude volume scope. +//! +//! Add an emit here (`InvalidationReason::VolumesChanged` or similar) +//! when a v2 frontend caches the volume list and needs a hint to +//! refetch — until then a silent write is correct. + +use std::sync::Arc; + +use perima_core::{CoreError, DeviceId, EventBus, Hlc, VolumeId, VolumeIdentifiers}; +use rusqlite::{Connection, OptionalExtension}; + +use crate::cmd::VolumeWriteCmd; +use crate::errors::Error; + +/// Writer-side dispatch for [`VolumeWriteCmd`]. Consumes the command +/// (the reply channel lives inside each variant) and sends the result +/// back on the caller's reply channel. +/// +/// WHY `_bus` unused: see module-level WHY block — volume writes +/// invalidate no v1 query index. The parameter stays in the +/// signature so adding a `VolumesChanged` invalidation in v2 is an +/// additive change in this one module, not a churn across +/// `writer/mod.rs`. +#[allow(clippy::needless_pass_by_value)] +pub(super) fn handle(conn: &mut Connection, cmd: VolumeWriteCmd, _bus: &Arc) { + // WHY one HLC per command (not per row): the "one HLC per + // user-visible logical event" invariant from spec §3.7. A single + // find_or_create may UPDATE an existing volume row; both paths + // bind the same `hlc` value. + let hlc = Hlc::now().pack(); + + match cmd { + VolumeWriteCmd::FindOrCreate { + identifiers, + device, + reply, + } => { + let out = find_or_create_impl(conn, &identifiers, device, hlc); + if reply.send(out).is_err() { + // WHY debug (not warn): caller dropped its reply + // handle — e.g. CLI aborted mid-command. The write + // already committed; nothing actionable. + tracing::debug!("volume find_or_create reply channel closed before send"); + } + } + VolumeWriteCmd::RecordMount { + volume, + device, + mount, + reply, + } => { + let out = record_mount_impl(conn, volume, device, &mount, hlc); + if reply.send(out).is_err() { + tracing::debug!("volume record_mount reply channel closed before send"); + } + } + } +} + +/// ISO-8601 UTC timestamp used for `updated_at` / `last_seen` / +/// `first_seen`. Matches the pre-Batch-C adapter's `now_iso` helper. +fn now_iso() -> String { + chrono::Utc::now().to_rfc3339() +} + +/// Cast `u64` capacity to the `i64` column width with an explicit +/// error on overflow. Shared between `find_or_create_impl` and the +/// INSERT path. +fn capacity_to_i64(cap: u64) -> Result { + i64::try_from(cap).map_err(|_| CoreError::Internal(format!("capacity {cap} overflows i64"))) +} + +/// Refresh `last_seen` / `updated_at` / `device_id` / `hlc` on an +/// existing volumes row and commit. Shared tail for all three match +/// arms (GUID / `fs_uuid` / label+capacity). +fn touch_and_commit( + tx: rusqlite::Transaction<'_>, + vol_id_str: &str, + now: &str, + dev_str: &str, + hlc: i64, +) -> Result { + tx.execute( + "UPDATE volumes + SET last_seen = ?1, updated_at = ?1, device_id = ?2, hlc = ?3 + WHERE volume_id = ?4", + rusqlite::params![now, dev_str, hlc, vol_id_str], + ) + .map_err(Error::from)?; + let vol_id = uuid::Uuid::parse_str(vol_id_str) + .map_err(|e| CoreError::Internal(format!("bad volume uuid: {e}")))?; + tx.commit().map_err(Error::from)?; + Ok(VolumeId(vol_id)) +} + +/// Writer-side body for [`VolumeWriteCmd::FindOrCreate`]. Lifted +/// verbatim from the pre-Batch-C `SqliteVolumeRepository::find_or_create` +/// with `hlc = ?` bound on every INSERT / UPDATE to `volumes`. +fn find_or_create_impl( + conn: &mut Connection, + ident: &VolumeIdentifiers, + device: DeviceId, + hlc: i64, +) -> Result { + let now = now_iso(); + let dev_str = device.0.to_string(); + + // WHY BEGIN IMMEDIATE: historical rationale (pre-Batch-C) guarded + // against two adapter handles racing on SELECT-then-INSERT. The + // single writer actor now guarantees serialization, BUT retaining + // IMMEDIATE is cheap and documents the "only one write tx at a + // time" expectation at the SQL boundary. Also protects against + // any stray future reader that happens to hold a read tx while + // this handler tries to promote. + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(Error::from)?; + + // WHY: priority chain — GUID is the most stable identifier (survives + // reformatting on the same hardware). fs_uuid is next. label+capacity + // is the v1 fallback. Each arm SELECT-then-UPDATE-last-seen, or falls + // through to the next. + + // Arm 1: GPT partition GUID + if let Some(ref guid) = ident.gpt_partition_guid { + let existing: Option = tx + .query_row( + "SELECT volume_id FROM volumes + WHERE gpt_partition_guid = ?1 AND deleted_at IS NULL", + [guid], + |row| row.get(0), + ) + .optional() + .map_err(Error::from)?; + + if let Some(vol_id_str) = existing { + return touch_and_commit(tx, &vol_id_str, &now, &dev_str, hlc); + } + } + + // Arm 2: Filesystem UUID + if let Some(ref fs_uuid) = ident.fs_uuid { + let existing: Option = tx + .query_row( + "SELECT volume_id FROM volumes + WHERE fs_uuid = ?1 AND deleted_at IS NULL", + [fs_uuid], + |row| row.get(0), + ) + .optional() + .map_err(Error::from)?; + + if let Some(vol_id_str) = existing { + return touch_and_commit(tx, &vol_id_str, &now, &dev_str, hlc); + } + } + + // Arm 3: label + capacity (v1 primary matching path) + if let Some(ref label) = ident.label { + let cap_i64 = capacity_to_i64(ident.capacity_bytes)?; + let existing: Option = tx + .query_row( + "SELECT volume_id FROM volumes + WHERE volume_label = ?1 AND capacity_bytes = ?2 + AND deleted_at IS NULL", + rusqlite::params![label, cap_i64], + |row| row.get(0), + ) + .optional() + .map_err(Error::from)?; + + if let Some(vol_id_str) = existing { + return touch_and_commit(tx, &vol_id_str, &now, &dev_str, hlc); + } + } + + // No match → INSERT new volume row. + let new_id = VolumeId::new(); + let new_id_str = new_id.0.to_string(); + let cap_i64 = capacity_to_i64(ident.capacity_bytes)?; + tx.execute( + "INSERT INTO volumes + (volume_id, gpt_partition_guid, fs_uuid, volume_label, + capacity_bytes, is_removable, last_seen, updated_at, device_id, hlc) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8, ?9)", + rusqlite::params![ + new_id_str, + ident.gpt_partition_guid, + ident.fs_uuid, + ident.label, + cap_i64, + i64::from(ident.is_removable), + now, + dev_str, + hlc, + ], + ) + .map_err(Error::from)?; + tx.commit().map_err(Error::from)?; + + Ok(new_id) +} + +/// Writer-side body for [`VolumeWriteCmd::RecordMount`]. Lifted +/// verbatim from the pre-Batch-C `SqliteVolumeRepository::record_mount`. +/// +/// No `hlc` binding: `volume_mounts` is device-local (V009 excludes it +/// from the sync-eligible table list — see module-doc). +fn record_mount_impl( + conn: &mut Connection, + volume: VolumeId, + machine: DeviceId, + mount: &std::path::Path, + _hlc: i64, +) -> Result<(), CoreError> { + // WHY: validate UTF-8 at the boundary. `to_string_lossy` silently + // replaces invalid bytes with U+FFFD, corrupting future identity + // matches. InvalidPath is the taxonomy hit used elsewhere for path + // problems that fail validation before we touch the DB. + let mount_str = mount.to_str().ok_or_else(|| { + CoreError::InvalidPath(format!( + "mount path is not valid UTF-8: {}", + mount.display() + )) + })?; + + let now = now_iso(); + let vol_str = volume.0.to_string(); + let machine_str = machine.0.to_string(); + + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(Error::from)?; + + // WHY: soft-delete any active mount row for the same + // (volume_id, machine_id) whose mount_path differs from the new one. + // CRDT rules forbid hard-delete on mutable rows; retired mounts + // remain observable to future sync with deleted_at set. + tx.execute( + "UPDATE volume_mounts + SET deleted_at = ?1, updated_at = ?1, device_id = ?2 + WHERE volume_id = ?3 AND machine_id = ?4 + AND mount_path <> ?5 AND deleted_at IS NULL", + rusqlite::params![now, machine_str, vol_str, machine_str, mount_str], + ) + .map_err(Error::from)?; + + // WHY: app-level uniqueness on (volume_id, machine_id, mount_path, + // deleted_at IS NULL) replaces a UNIQUE constraint that CLAUDE.md + // forbids on mutable columns. Under the writer actor SELECT-then- + // INSERT is race-safe because this is the only writer. + let existing: Option = tx + .query_row( + "SELECT id FROM volume_mounts + WHERE volume_id = ?1 AND machine_id = ?2 + AND mount_path = ?3 AND deleted_at IS NULL", + rusqlite::params![vol_str, machine_str, mount_str], + |row| row.get(0), + ) + .optional() + .map_err(Error::from)?; + + if existing.is_none() { + let new_id = perima_core::ids::new_id().to_string(); + tx.execute( + "INSERT INTO volume_mounts + (id, volume_id, machine_id, mount_path, first_seen, updated_at, device_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6)", + rusqlite::params![new_id, vol_str, machine_str, mount_str, now, machine_str,], + ) + .map_err(Error::from)?; + } + + tx.commit().map_err(Error::from)?; + Ok(()) +} diff --git a/crates/db/tests/writer_emits_file_status_change.rs b/crates/db/tests/writer_emits_file_status_change.rs new file mode 100644 index 0000000..ac91961 --- /dev/null +++ b/crates/db/tests/writer_emits_file_status_change.rs @@ -0,0 +1,98 @@ +//! Verify `WriteCmd::File(FileWriteCmd::UpdateLocationStatus)` emits +//! `AppEvent::IndexInvalidated { reason: FilesChanged }` on the bus +//! AFTER a successful COMMIT (Batch E Task 8). + +#![allow(clippy::unwrap_used)] // WHY: integration test; unwrap panics signal bugs. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use perima_core::{ + AppEvent, BlakeHash, CoreError, DeviceId, DiscoveredFile, EventBus, FileRepository, FileSize, + HashedFile, InvalidationReason, LocationStatus, MediaPath, VolumeId, +}; +use perima_db::{ReadPool, SqliteFileRepository, SqliteWriter}; + +#[derive(Debug, Default)] +struct RecordingBus { + events: Mutex>, +} + +impl EventBus for RecordingBus { + fn emit(&self, event: &AppEvent) -> Result<(), CoreError> { + self.events.lock().unwrap().push(event.clone()); + Ok(()) + } +} + +fn sample_hashed_file(content: &[u8], rel_path: &str) -> HashedFile { + let hash = BlakeHash::from_bytes(*blake3::hash(content).as_bytes()); + HashedFile { + discovered: DiscoveredFile { + absolute_path: PathBuf::from("/tmp/fake"), + relative_path: MediaPath::new(rel_path), + size: FileSize(content.len() as u64), + }, + hash, + } +} + +#[test] +fn file_update_location_status_emits_index_invalidated_files_changed() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("perima.db"); + + let bus = Arc::new(RecordingBus::default()); + let bus_for_writer: Arc = bus.clone(); + let writer = SqliteWriter::start(&db_path, bus_for_writer).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let repo = SqliteFileRepository::new(writer.sender(), reads); + + let dev = DeviceId::new(); + let vol = VolumeId::new(); + let f = sample_hashed_file(b"status_change_emit_test", "missing.jpg"); + let path = MediaPath::new("missing.jpg"); + + // Seed: file + active location. Both seed steps emit; drain. + repo.upsert_file(&f, dev).unwrap(); + repo.upsert_location(&f.hash, vol, &path, dev).unwrap(); + + bus.events.lock().unwrap().clear(); + + let n = repo + .update_location_status(vol, &path, LocationStatus::Missing, dev) + .unwrap(); + assert_eq!(n, 1, "status update must touch exactly 1 row"); + + let events = bus.events.lock().unwrap(); + assert_eq!(events.len(), 1, "expected 1 event, got {events:?}"); + assert!( + matches!( + events[0], + AppEvent::IndexInvalidated { + reason: InvalidationReason::FilesChanged + } + ), + "expected IndexInvalidated::FilesChanged, got {:?}", + events[0] + ); + drop(events); + + // No-op status update against a non-existent (volume, path) MUST + // NOT emit — the impl writes zero rows. + bus.events.lock().unwrap().clear(); + let nonexistent = MediaPath::new("does-not-exist.jpg"); + let n2 = repo + .update_location_status(vol, &nonexistent, LocationStatus::Stale, dev) + .unwrap(); + assert_eq!(n2, 0); + let events_after_noop = bus.events.lock().unwrap(); + assert!( + events_after_noop.is_empty(), + "no-op status update should NOT emit (no row written), got {events_after_noop:?}" + ); + drop(events_after_noop); + + drop(repo); + writer.join(); +} diff --git a/crates/db/tests/writer_emits_file_upsert.rs b/crates/db/tests/writer_emits_file_upsert.rs new file mode 100644 index 0000000..17c60c3 --- /dev/null +++ b/crates/db/tests/writer_emits_file_upsert.rs @@ -0,0 +1,92 @@ +//! Verify `WriteCmd::File(FileWriteCmd::UpsertLocation)` emits +//! `AppEvent::IndexInvalidated { reason: FilesChanged }` on the bus +//! AFTER a successful COMMIT (Batch E Task 8). + +#![allow(clippy::unwrap_used)] // WHY: integration test; unwrap panics signal bugs. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use perima_core::{ + AppEvent, BlakeHash, CoreError, DeviceId, DiscoveredFile, EventBus, FileRepository, FileSize, + HashedFile, InvalidationReason, MediaPath, VolumeId, +}; +use perima_db::{ReadPool, SqliteFileRepository, SqliteWriter}; + +#[derive(Debug, Default)] +struct RecordingBus { + events: Mutex>, +} + +impl EventBus for RecordingBus { + fn emit(&self, event: &AppEvent) -> Result<(), CoreError> { + self.events.lock().unwrap().push(event.clone()); + Ok(()) + } +} + +fn sample_hashed_file(content: &[u8], rel_path: &str) -> HashedFile { + let hash = BlakeHash::from_bytes(*blake3::hash(content).as_bytes()); + HashedFile { + discovered: DiscoveredFile { + absolute_path: PathBuf::from("/tmp/fake"), + relative_path: MediaPath::new(rel_path), + size: FileSize(content.len() as u64), + }, + hash, + } +} + +#[test] +fn file_upsert_location_emits_index_invalidated_files_changed() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("perima.db"); + + let bus = Arc::new(RecordingBus::default()); + let bus_for_writer: Arc = bus.clone(); + let writer = SqliteWriter::start(&db_path, bus_for_writer).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let repo = SqliteFileRepository::new(writer.sender(), reads); + + let dev = DeviceId::new(); + let vol = VolumeId::new(); + let f = sample_hashed_file(b"file_upsert_emit_test", "photo.jpg"); + let path = MediaPath::new("photo.jpg"); + + // Seed the files row first. UpsertFile is itself a FilesChanged + // emitter (per writer/file.rs handle); we drain that emit before + // the targeted UpsertLocation assertion. + repo.upsert_file(&f, dev).unwrap(); + + bus.events.lock().unwrap().clear(); + + repo.upsert_location(&f.hash, vol, &path, dev).unwrap(); + + let events = bus.events.lock().unwrap(); + assert_eq!(events.len(), 1, "expected 1 event, got {events:?}"); + assert!( + matches!( + events[0], + AppEvent::IndexInvalidated { + reason: InvalidationReason::FilesChanged + } + ), + "expected IndexInvalidated::FilesChanged, got {:?}", + events[0] + ); + drop(events); + + // Unchanged arm (repeat upsert with same hash + device) MUST NOT + // emit — the writer wrote zero rows on the Unchanged path. + bus.events.lock().unwrap().clear(); + repo.upsert_location(&f.hash, vol, &path, dev).unwrap(); + let events_after_unchanged = bus.events.lock().unwrap(); + assert!( + events_after_unchanged.is_empty(), + "Unchanged upsert should NOT emit, got {events_after_unchanged:?}" + ); + drop(events_after_unchanged); + + drop(repo); + writer.join(); +} diff --git a/crates/db/tests/writer_emits_metadata_attach.rs b/crates/db/tests/writer_emits_metadata_attach.rs new file mode 100644 index 0000000..13edf43 --- /dev/null +++ b/crates/db/tests/writer_emits_metadata_attach.rs @@ -0,0 +1,105 @@ +//! Verify `WriteCmd::Metadata(MetadataWriteCmd::UpsertMetadata)` emits +//! `AppEvent::IndexInvalidated { reason: MetadataChanged }` on the bus +//! AFTER a successful COMMIT (Batch E Task 8). +//! +//! Spec uses "`MetadataAttach`" as a descriptive name; the actual +//! `MetadataWriteCmd` variant is `UpsertMetadata` — the writer's +//! INSERT branch is the metadata-attach logical event. + +#![allow(clippy::unwrap_used)] // WHY: integration test; unwrap panics signal bugs. + +use std::sync::{Arc, Mutex}; + +use perima_core::{ + AppEvent, BlakeHash, CoreError, DeviceId, EventBus, InvalidationReason, MediaMetadata, + MetadataRepository, +}; +use perima_db::{ReadPool, SqliteMetadataRepository, SqliteWriter}; + +#[derive(Debug, Default)] +struct RecordingBus { + events: Mutex>, +} + +impl EventBus for RecordingBus { + fn emit(&self, event: &AppEvent) -> Result<(), CoreError> { + self.events.lock().unwrap().push(event.clone()); + Ok(()) + } +} + +fn sample_metadata() -> MediaMetadata { + let hash = BlakeHash::parse_hex(&"b".repeat(64)).expect("hash"); + MediaMetadata { + hash, + width: Some(3840), + height: Some(2160), + duration_ms: None, + captured_at: Some("2025-01-01T00:00:00Z".into()), + camera_make: None, + camera_model: None, + codec: None, + bitrate_bps: None, + mime_type: Some("image/jpeg".into()), + thumbnail_path: None, + thumbnail_status: None, + } +} + +#[test] +fn metadata_upsert_emits_index_invalidated_metadata_changed() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("perima.db"); + + let bus = Arc::new(RecordingBus::default()); + let bus_for_writer: Arc = bus.clone(); + let writer = SqliteWriter::start(&db_path, bus_for_writer).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let repo = SqliteMetadataRepository::new(writer.sender(), reads); + + let dev = DeviceId::new(); + let meta = sample_metadata(); + + // INSERT path → MetadataChanged emit. + repo.upsert_metadata(&meta, dev).unwrap(); + + let events = bus.events.lock().unwrap(); + assert_eq!(events.len(), 1, "expected 1 event, got {events:?}"); + assert!( + matches!( + events[0], + AppEvent::IndexInvalidated { + reason: InvalidationReason::MetadataChanged + } + ), + "expected IndexInvalidated::MetadataChanged, got {:?}", + events[0] + ); + drop(events); + + // Unchanged arm (re-upsert with identical inputs) MUST NOT emit. + bus.events.lock().unwrap().clear(); + repo.upsert_metadata(&meta, dev).unwrap(); + let events_after_unchanged = bus.events.lock().unwrap(); + assert!( + events_after_unchanged.is_empty(), + "Unchanged metadata upsert should NOT emit, got {events_after_unchanged:?}" + ); + drop(events_after_unchanged); + + // Updated arm (mime_type flip) MUST emit again. + bus.events.lock().unwrap().clear(); + let mut meta2 = meta; + meta2.mime_type = Some("image/png".into()); + repo.upsert_metadata(&meta2, dev).unwrap(); + let events_after_update = bus.events.lock().unwrap(); + assert_eq!( + events_after_update.len(), + 1, + "Updated metadata upsert should emit exactly 1 event, got {events_after_update:?}" + ); + drop(events_after_update); + + drop(repo); + writer.join(); +} diff --git a/crates/db/tests/writer_emits_search_rebuild.rs b/crates/db/tests/writer_emits_search_rebuild.rs new file mode 100644 index 0000000..2a5345b --- /dev/null +++ b/crates/db/tests/writer_emits_search_rebuild.rs @@ -0,0 +1,67 @@ +//! Verify `WriteCmd::Search(SearchWriteCmd::Rebuild)` emits +//! `AppEvent::IndexInvalidated { reason: SearchIndexRebuilt }` on +//! the bus AFTER a successful COMMIT (Batch E Task 8). + +#![allow(clippy::unwrap_used)] // WHY: integration test; unwrap panics signal bugs. + +use std::sync::{Arc, Mutex}; + +use perima_core::{AppEvent, CoreError, EventBus, InvalidationReason, SearchRepository}; +use perima_db::{ReadPool, SqliteSearchRepository, SqliteWriter}; + +#[derive(Debug, Default)] +struct RecordingBus { + events: Mutex>, +} + +impl EventBus for RecordingBus { + fn emit(&self, event: &AppEvent) -> Result<(), CoreError> { + self.events.lock().unwrap().push(event.clone()); + Ok(()) + } +} + +#[test] +fn search_rebuild_emits_index_invalidated_search_index_rebuilt() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("perima.db"); + + let bus = Arc::new(RecordingBus::default()); + let bus_for_writer: Arc = bus.clone(); + let writer = SqliteWriter::start(&db_path, bus_for_writer).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let repo = SqliteSearchRepository::new(writer.sender(), reads); + + // Rebuild against an empty source set is still a logical event: + // the FTS index state changed (cleared + reseeded with zero rows). + repo.rebuild().unwrap(); + + let events = bus.events.lock().unwrap(); + assert_eq!(events.len(), 1, "expected 1 event, got {events:?}"); + assert!( + matches!( + events[0], + AppEvent::IndexInvalidated { + reason: InvalidationReason::SearchIndexRebuilt + } + ), + "expected IndexInvalidated::SearchIndexRebuilt, got {:?}", + events[0] + ); + drop(events); + + // Second rebuild MUST emit again — every successful rebuild is + // its own logical event regardless of source-state churn. + bus.events.lock().unwrap().clear(); + repo.rebuild().unwrap(); + let events_after_second = bus.events.lock().unwrap(); + assert_eq!( + events_after_second.len(), + 1, + "second rebuild should emit again, got {events_after_second:?}" + ); + drop(events_after_second); + + drop(repo); + writer.join(); +} diff --git a/crates/db/tests/writer_emits_tag_attach.rs b/crates/db/tests/writer_emits_tag_attach.rs new file mode 100644 index 0000000..ea6b201 --- /dev/null +++ b/crates/db/tests/writer_emits_tag_attach.rs @@ -0,0 +1,87 @@ +//! Verify `WriteCmd::Tag(TagWriteCmd::Attach)` emits +//! `AppEvent::IndexInvalidated { reason: TagsChanged }` on the bus +//! AFTER a successful COMMIT (Batch E Task 8). + +#![allow(clippy::unwrap_used)] // WHY: integration test; unwrap panics signal bugs. + +use std::sync::{Arc, Mutex}; + +use perima_core::{ + AppEvent, BlakeHash, CoreError, DeviceId, EventBus, InvalidationReason, TagRepository, +}; +use perima_db::{ReadPool, SqliteTagRepository, SqliteWriter}; + +/// Recording bus that captures every emit for assertion. +/// +/// WHY inlined per test file (vs a shared helper): consolidation +/// isn't worth a dedicated `test_utils` module for 6 files — each +/// test owns its bus + its assertion shape, and the impl is 4 lines. +#[derive(Debug, Default)] +struct RecordingBus { + events: Mutex>, +} + +impl EventBus for RecordingBus { + fn emit(&self, event: &AppEvent) -> Result<(), CoreError> { + self.events.lock().unwrap().push(event.clone()); + Ok(()) + } +} + +#[test] +fn tag_attach_emits_index_invalidated_tags_changed() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("perima.db"); + + let bus = Arc::new(RecordingBus::default()); + let bus_for_writer: Arc = bus.clone(); + let writer = SqliteWriter::start(&db_path, bus_for_writer).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let repo = SqliteTagRepository::new(writer.sender(), reads); + + let dev = DeviceId::new(); + + // Seed: create the tag (UpsertTag does NOT emit by design — only + // Attach/Detach are wired in Batch E Task 8). + let tag = repo.upsert_tag("photo", dev).unwrap(); + + // Hash to attach against — files row need not exist for the + // attach to land (file_tags has no FK cascade per CLAUDE.md + // schema rules). + let hash = BlakeHash::from_bytes(*blake3::hash(b"attach_emit_test").as_bytes()); + + // Snapshot: any seed-time emits drained. + bus.events.lock().unwrap().clear(); + + repo.attach(&hash, tag.id, dev).unwrap(); + + // The writer thread emits BEFORE replying (Approach B), and the + // adapter blocks on reply, so by the time `attach` returns the + // emit has already happened — no sleep needed. + let events = bus.events.lock().unwrap(); + assert_eq!(events.len(), 1, "expected 1 event, got {events:?}"); + assert!( + matches!( + events[0], + AppEvent::IndexInvalidated { + reason: InvalidationReason::TagsChanged + } + ), + "expected IndexInvalidated::TagsChanged, got {:?}", + events[0] + ); + drop(events); + + // No-op idempotent attach (same pair) MUST NOT emit a second event. + bus.events.lock().unwrap().clear(); + repo.attach(&hash, tag.id, dev).unwrap(); + let events_after_noop = bus.events.lock().unwrap(); + assert!( + events_after_noop.is_empty(), + "idempotent re-attach should NOT emit (no row written), got {events_after_noop:?}" + ); + drop(events_after_noop); + + drop(repo); + writer.join(); +} diff --git a/crates/db/tests/writer_emits_tag_detach.rs b/crates/db/tests/writer_emits_tag_detach.rs new file mode 100644 index 0000000..f427614 --- /dev/null +++ b/crates/db/tests/writer_emits_tag_detach.rs @@ -0,0 +1,76 @@ +//! Verify `WriteCmd::Tag(TagWriteCmd::Detach)` emits +//! `AppEvent::IndexInvalidated { reason: TagsChanged }` on the bus +//! AFTER a successful COMMIT (Batch E Task 8). + +#![allow(clippy::unwrap_used)] // WHY: integration test; unwrap panics signal bugs. + +use std::sync::{Arc, Mutex}; + +use perima_core::{ + AppEvent, BlakeHash, CoreError, DeviceId, EventBus, InvalidationReason, TagRepository, +}; +use perima_db::{ReadPool, SqliteTagRepository, SqliteWriter}; + +#[derive(Debug, Default)] +struct RecordingBus { + events: Mutex>, +} + +impl EventBus for RecordingBus { + fn emit(&self, event: &AppEvent) -> Result<(), CoreError> { + self.events.lock().unwrap().push(event.clone()); + Ok(()) + } +} + +#[test] +fn tag_detach_emits_index_invalidated_tags_changed() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("perima.db"); + + let bus = Arc::new(RecordingBus::default()); + let bus_for_writer: Arc = bus.clone(); + let writer = SqliteWriter::start(&db_path, bus_for_writer).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let repo = SqliteTagRepository::new(writer.sender(), reads); + + let dev = DeviceId::new(); + + // Seed: create tag + attach. We expect a TagsChanged emit from + // the seed-attach; we drain it before the assertion. + let tag = repo.upsert_tag("video", dev).unwrap(); + let hash = BlakeHash::from_bytes(*blake3::hash(b"detach_emit_test").as_bytes()); + repo.attach(&hash, tag.id, dev).unwrap(); + + // Drain the seed emits. + bus.events.lock().unwrap().clear(); + + repo.detach(&hash, tag.id, dev).unwrap(); + + let events = bus.events.lock().unwrap(); + assert_eq!(events.len(), 1, "expected 1 event, got {events:?}"); + assert!( + matches!( + events[0], + AppEvent::IndexInvalidated { + reason: InvalidationReason::TagsChanged + } + ), + "expected IndexInvalidated::TagsChanged, got {:?}", + events[0] + ); + drop(events); + + // No-op idempotent detach (already detached pair) MUST NOT emit. + bus.events.lock().unwrap().clear(); + repo.detach(&hash, tag.id, dev).unwrap(); + let events_after_noop = bus.events.lock().unwrap(); + assert!( + events_after_noop.is_empty(), + "idempotent re-detach should NOT emit (no row written), got {events_after_noop:?}" + ); + drop(events_after_noop); + + drop(repo); + writer.join(); +} diff --git a/crates/db/tests/writer_hlc_file.rs b/crates/db/tests/writer_hlc_file.rs new file mode 100644 index 0000000..b3b2668 --- /dev/null +++ b/crates/db/tests/writer_hlc_file.rs @@ -0,0 +1,320 @@ +//! Integration test for Batch C Task 5 acceptance criterion A4.4: +//! every write that touches an HLC-bearing row must populate `hlc`. +//! +//! Run `SqliteFileRepository::{upsert_file, upsert_location, +//! update_location_status, update_location_path, migrate_sentinel_row}` +//! via the writer actor, then open a raw read-only +//! [`rusqlite::Connection`] against the same tempfile-backed DB and +//! assert `hlc IS NOT NULL` and strictly increasing on every write. +//! +//! Covers: +//! +//! - INSERT path of `upsert_file` → `files.hlc` populated + > 0. +//! - UPDATE path of `upsert_file` (size flip) → `files.hlc` strictly +//! greater than the INSERT value. +//! - `Unchanged` arm of `upsert_file` → `files.hlc` MUST stay equal +//! (no write happened). +//! - INSERT path of `upsert_location` → `file_locations.hlc` populated. +//! - UPDATE path of `upsert_location` (hash flip) → `file_locations.hlc` +//! strictly greater than INSERT value. +//! - `Unchanged` arm of `upsert_location` → `file_locations.hlc` must +//! stay equal. +//! - `update_location_status` → `file_locations.hlc` strictly greater. +//! - `update_location_path` (rename) → `file_locations.hlc` strictly +//! greater. +//! - `migrate_sentinel_row` → `file_locations.hlc` strictly greater. + +#![allow(clippy::unwrap_used)] // WHY: integration test; unwrap panics signal bugs. + +use std::path::PathBuf; +use std::sync::Arc; + +use perima_core::{ + BlakeHash, DeviceId, EventBus, FileRepository, FileSize, HashedFile, LocationStatus, MediaPath, + UpsertOutcome, VolumeId, +}; +use perima_db::{ReadPool, SqliteFileRepository, SqliteWriter, test_utils::NoopBus}; +use rusqlite::{Connection, OpenFlags}; + +fn sample_hashed_file(content: &[u8], rel_path: &str) -> HashedFile { + let hash = BlakeHash::from_bytes(*blake3::hash(content).as_bytes()); + HashedFile { + discovered: perima_core::DiscoveredFile { + absolute_path: PathBuf::from("/tmp/fake"), + relative_path: MediaPath::new(rel_path), + size: FileSize(content.len() as u64), + }, + hash, + } +} + +fn read_files_hlc(ro: &Connection, hash_hex: &str) -> Option { + ro.query_row( + "SELECT hlc FROM files WHERE blake3_hash = ?1", + [hash_hex], + |row| row.get(0), + ) + .expect("select files.hlc") +} + +fn read_location_hlc(ro: &Connection, vol_str: &str, path_str: &str) -> Option { + ro.query_row( + "SELECT hlc FROM file_locations + WHERE volume_id = ?1 AND relative_path = ?2 AND deleted_at IS NULL", + [vol_str, path_str], + |row| row.get(0), + ) + .expect("select file_locations.hlc") +} + +#[test] +fn upsert_file_populates_hlc() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("perima.db"); + + let bus: Arc = Arc::new(NoopBus); + let writer = SqliteWriter::start(&db_path, bus).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let repo = SqliteFileRepository::new(writer.sender(), reads); + + let dev = DeviceId::new(); + let f = sample_hashed_file(b"hlc_file_test", "test.jpg"); + let hash_hex = f.hash.to_hex(); + + // Raw read-only connection to verify column values directly. + let ro = Connection::open_with_flags( + &db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .unwrap(); + + // INSERT path → files.hlc must be populated + > 0. + let out = repo.upsert_file(&f, dev).unwrap(); + assert_eq!(out, UpsertOutcome::Inserted); + let inserted_hlc = read_files_hlc(&ro, &hash_hex).expect("hlc must NOT be NULL after insert"); + assert!(inserted_hlc > 0, "packed HLC must be positive i64"); + + // Unchanged arm (repeat with same size + device) → hlc MUST stay equal. + let out2 = repo.upsert_file(&f, dev).unwrap(); + assert_eq!(out2, UpsertOutcome::Unchanged); + let unchanged_hlc = read_files_hlc(&ro, &hash_hex).expect("hlc present"); + assert_eq!( + unchanged_hlc, inserted_hlc, + "Unchanged arm must NOT bump files.hlc (no write happened)" + ); + + // UPDATE path (size flip) → hlc strictly greater. + let mut f2 = f; + f2.discovered.size = FileSize(9999); + let out3 = repo.upsert_file(&f2, dev).unwrap(); + assert_eq!(out3, UpsertOutcome::Updated); + let updated_hlc = read_files_hlc(&ro, &hash_hex).expect("hlc after update"); + assert!( + updated_hlc > inserted_hlc, + "Updated arm must refresh files.hlc to strictly greater value \ + (got {updated_hlc} <= {inserted_hlc})" + ); + + drop(repo); + writer.join(); +} + +#[test] +fn upsert_location_populates_hlc() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("perima.db"); + + let bus: Arc = Arc::new(NoopBus); + let writer = SqliteWriter::start(&db_path, bus).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let repo = SqliteFileRepository::new(writer.sender(), reads); + + let dev = DeviceId::new(); + let vol = VolumeId::new(); + let f1 = sample_hashed_file(b"loc_hlc_v1", "loc.jpg"); + let f2 = sample_hashed_file(b"loc_hlc_v2", "loc.jpg"); + let path = MediaPath::new("loc.jpg"); + let vol_str = vol.0.to_string(); + + let ro = Connection::open_with_flags( + &db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .unwrap(); + + // Seed the files rows. + repo.upsert_file(&f1, dev).unwrap(); + repo.upsert_file(&f2, dev).unwrap(); + + // INSERT → file_locations.hlc populated + > 0. + let out = repo.upsert_location(&f1.hash, vol, &path, dev).unwrap(); + assert_eq!(out, UpsertOutcome::Inserted); + let inserted_hlc = + read_location_hlc(&ro, &vol_str, "loc.jpg").expect("hlc must NOT be NULL after insert"); + assert!(inserted_hlc > 0, "packed HLC must be positive i64"); + + // Unchanged arm (repeat) → hlc stays equal. + let out2 = repo.upsert_location(&f1.hash, vol, &path, dev).unwrap(); + assert_eq!(out2, UpsertOutcome::Unchanged); + let unchanged_hlc = read_location_hlc(&ro, &vol_str, "loc.jpg").expect("hlc present"); + assert_eq!( + unchanged_hlc, inserted_hlc, + "Unchanged arm must NOT bump file_locations.hlc" + ); + + // UPDATE path (hash flip) → hlc strictly greater. + let out3 = repo.upsert_location(&f2.hash, vol, &path, dev).unwrap(); + assert_eq!(out3, UpsertOutcome::Updated); + let updated_hlc = read_location_hlc(&ro, &vol_str, "loc.jpg").expect("hlc after update"); + assert!( + updated_hlc > inserted_hlc, + "Updated arm must refresh file_locations.hlc to strictly greater value \ + (got {updated_hlc} <= {inserted_hlc})" + ); + + drop(repo); + writer.join(); +} + +#[test] +fn update_location_status_bumps_hlc() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("perima.db"); + + let bus: Arc = Arc::new(NoopBus); + let writer = SqliteWriter::start(&db_path, bus).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let repo = SqliteFileRepository::new(writer.sender(), reads); + + let dev = DeviceId::new(); + let vol = VolumeId::new(); + let f = sample_hashed_file(b"status_hlc", "status.jpg"); + let path = MediaPath::new("status.jpg"); + let vol_str = vol.0.to_string(); + + let ro = Connection::open_with_flags( + &db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .unwrap(); + + repo.upsert_file(&f, dev).unwrap(); + repo.upsert_location(&f.hash, vol, &path, dev).unwrap(); + let prior_hlc = read_location_hlc(&ro, &vol_str, "status.jpg").expect("prior hlc"); + + let n = repo + .update_location_status(vol, &path, LocationStatus::Missing, dev) + .unwrap(); + assert_eq!(n, 1); + + let after_hlc = + read_location_hlc(&ro, &vol_str, "status.jpg").expect("hlc after status update"); + assert!( + after_hlc > prior_hlc, + "update_location_status must refresh file_locations.hlc to strictly greater value \ + (got {after_hlc} <= {prior_hlc})" + ); + + drop(repo); + writer.join(); +} + +#[test] +fn update_location_path_bumps_hlc() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("perima.db"); + + let bus: Arc = Arc::new(NoopBus); + let writer = SqliteWriter::start(&db_path, bus).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let repo = SqliteFileRepository::new(writer.sender(), reads); + + let dev = DeviceId::new(); + let vol = VolumeId::new(); + let f = sample_hashed_file(b"path_hlc", "old.jpg"); + let old_path = MediaPath::new("old.jpg"); + let new_path = MediaPath::new("new.jpg"); + let vol_str = vol.0.to_string(); + + let ro = Connection::open_with_flags( + &db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .unwrap(); + + repo.upsert_file(&f, dev).unwrap(); + repo.upsert_location(&f.hash, vol, &old_path, dev).unwrap(); + let prior_hlc = read_location_hlc(&ro, &vol_str, "old.jpg").expect("prior hlc"); + + let n = repo + .update_location_path(vol, &old_path, &new_path, dev) + .unwrap(); + assert_eq!(n, 1); + + // Row moved to new_path — check hlc there. + let after_hlc = read_location_hlc(&ro, &vol_str, "new.jpg").expect("hlc after path update"); + assert!( + after_hlc > prior_hlc, + "update_location_path must refresh file_locations.hlc to strictly greater value \ + (got {after_hlc} <= {prior_hlc})" + ); + + drop(repo); + writer.join(); +} + +#[test] +fn migrate_sentinel_row_bumps_hlc() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("perima.db"); + + let bus: Arc = Arc::new(NoopBus); + let writer = SqliteWriter::start(&db_path, bus).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let repo = SqliteFileRepository::new(writer.sender(), reads); + + let dev = DeviceId::new(); + let sentinel_vol = VolumeId(uuid::Uuid::nil()); + let real_vol = VolumeId::new(); + let f = sample_hashed_file(b"sentinel_hlc", "sentinel.jpg"); + let path = MediaPath::new("sentinel.jpg"); + let real_vol_str = real_vol.0.to_string(); + + let ro = Connection::open_with_flags( + &db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .unwrap(); + + repo.upsert_file(&f, dev).unwrap(); + // Insert with sentinel volume. + repo.upsert_location(&f.hash, sentinel_vol, &path, dev) + .unwrap(); + + // Read hlc before sentinel migration (row currently has sentinel vol UUID). + let sentinel_vol_str = sentinel_vol.0.to_string(); + let prior_hlc = ro + .query_row( + "SELECT hlc FROM file_locations + WHERE volume_id = ?1 AND relative_path = ?2 AND deleted_at IS NULL", + [&sentinel_vol_str, "sentinel.jpg"], + |row| row.get::<_, Option>(0), + ) + .expect("select sentinel hlc") + .expect("sentinel hlc must be set"); + + let n = repo.migrate_sentinel_row(&path, real_vol, dev).unwrap(); + assert_eq!(n, 1); + + // After migration the row lives under the real_vol UUID. + let after_hlc = + read_location_hlc(&ro, &real_vol_str, "sentinel.jpg").expect("hlc after sentinel migrate"); + assert!( + after_hlc > prior_hlc, + "migrate_sentinel_row must refresh file_locations.hlc to strictly greater value \ + (got {after_hlc} <= {prior_hlc})" + ); + + drop(repo); + writer.join(); +} diff --git a/crates/db/tests/writer_hlc_metadata.rs b/crates/db/tests/writer_hlc_metadata.rs new file mode 100644 index 0000000..66e2925 --- /dev/null +++ b/crates/db/tests/writer_hlc_metadata.rs @@ -0,0 +1,116 @@ +//! Integration test for Batch C Task 4 acceptance criterion A4.4: +//! every write that touches an HLC-bearing row must populate `hlc`. +//! +//! Run `SqliteMetadataRepository::{upsert_metadata, update_thumbnail}` +//! via the writer actor, then open a raw read-only +//! [`rusqlite::Connection`] against the same tempfile-backed DB and +//! assert `hlc IS NOT NULL` on every write. Covers: +//! +//! - INSERT path of `upsert_metadata` → `hlc` populated. +//! - UPDATE path (triggered by a `mime_type` flip on the same hash) → +//! `hlc` strictly greater than the insert value. +//! - `update_thumbnail` (independent logical event) → `hlc` strictly +//! greater than the prior value. +//! - `Unchanged` arm (second upsert with identical inputs) → `hlc` +//! MUST stay equal to the prior value (no write happened). + +#![allow(clippy::unwrap_used)] // WHY: integration test; unwrap panics signal bugs. + +use std::sync::Arc; + +use perima_core::{BlakeHash, DeviceId, EventBus, MediaMetadata, MetadataRepository}; +use perima_db::{ReadPool, SqliteMetadataRepository, SqliteWriter, test_utils::NoopBus}; +use rusqlite::{Connection, OpenFlags}; + +fn sample_metadata() -> MediaMetadata { + let hash = BlakeHash::parse_hex(&"a".repeat(64)).expect("hash"); + MediaMetadata { + hash, + width: Some(1920), + height: Some(1080), + duration_ms: None, + captured_at: Some("2024-06-01T12:34:56Z".into()), + camera_make: Some("Canon".into()), + camera_model: Some("EOS R5".into()), + codec: None, + bitrate_bps: None, + mime_type: Some("image/jpeg".into()), + thumbnail_path: None, + thumbnail_status: None, + } +} + +fn read_hlc(ro: &Connection, hash_hex: &str) -> Option { + ro.query_row( + "SELECT hlc FROM file_metadata WHERE blake3_hash = ?1 AND deleted_at IS NULL", + [hash_hex], + |row| row.get(0), + ) + .expect("select") +} + +#[test] +fn upsert_and_update_thumbnail_populate_hlc() { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("perima.db"); + + let bus: Arc = Arc::new(NoopBus); + let writer = SqliteWriter::start(&db_path, bus).expect("writer start"); + let reads = ReadPool::open(&db_path).expect("pool open"); + let repo = SqliteMetadataRepository::new(writer.sender(), reads); + + let dev = DeviceId::new(); + let meta = sample_metadata(); + let hash_hex = meta.hash.to_hex(); + + repo.upsert_metadata(&meta, dev).expect("insert"); + + // Raw read-only Connection to sidestep the adapter + pool so we + // verify the column was actually written. + let ro = Connection::open_with_flags( + &db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .expect("readonly open"); + + let inserted_hlc = + read_hlc(&ro, &hash_hex).expect("hlc must be NOT NULL after upsert_metadata insert"); + assert!(inserted_hlc > 0, "packed HLC must be positive i64"); + + // Second upsert with identical inputs → Unchanged arm; hlc must + // remain exactly equal (no write happened). + repo.upsert_metadata(&meta, dev).expect("unchanged"); + let unchanged_hlc = read_hlc(&ro, &hash_hex).expect("hlc present"); + assert_eq!( + unchanged_hlc, inserted_hlc, + "Unchanged upsert must NOT bump hlc (no write happened)" + ); + + // Third upsert with a flipped mime_type → UPDATE arm; hlc must + // refresh to a strictly greater value. + let mut meta2 = meta.clone(); + meta2.mime_type = Some("image/png".into()); + repo.upsert_metadata(&meta2, dev).expect("update"); + let updated_hlc = read_hlc(&ro, &hash_hex).expect("hlc after update"); + assert!( + updated_hlc > inserted_hlc, + "Updated upsert should refresh hlc to a strictly greater value \ + (got {updated_hlc} <= {inserted_hlc})" + ); + + // Thumbnail flip: independent logical event → hlc strictly greater + // than the prior UPDATE value. + repo.update_thumbnail(&meta.hash, Some("/data/t/ab/cd.webp"), "ready", dev) + .expect("update_thumbnail"); + let thumbnail_hlc = read_hlc(&ro, &hash_hex).expect("hlc after thumbnail"); + assert!( + thumbnail_hlc > updated_hlc, + "update_thumbnail should refresh hlc to a strictly greater value \ + (got {thumbnail_hlc} <= {updated_hlc})" + ); + + // Tear down explicitly — drops the repo's sender + reaps the + // writer thread cleanly before the tempdir is removed. + drop(repo); + writer.join(); +} diff --git a/crates/db/tests/writer_hlc_volume.rs b/crates/db/tests/writer_hlc_volume.rs new file mode 100644 index 0000000..e5769c8 --- /dev/null +++ b/crates/db/tests/writer_hlc_volume.rs @@ -0,0 +1,89 @@ +//! Integration test for Batch C Task 2 acceptance criterion A4.4: +//! every write that touches an HLC-bearing row must populate `hlc`. +//! +//! Run `SqliteVolumeRepository::find_or_create` via the writer actor, +//! then open a raw read-only [`rusqlite::Connection`] against the same +//! tempfile-backed DB and assert `hlc IS NOT NULL` on the inserted +//! row. Also exercises the UPDATE path (second `find_or_create` for +//! the same identifiers) and asserts the updated row's `hlc` is +//! strictly greater than the first — `Hlc::now()` is monotonically +//! non-decreasing. + +#![allow(clippy::unwrap_used)] // WHY: integration test; unwrap panics signal bugs. + +use std::sync::Arc; + +use perima_core::{DeviceId, EventBus, VolumeIdentifiers, VolumeRepository}; +use perima_db::{ReadPool, SqliteVolumeRepository, SqliteWriter, test_utils::NoopBus}; +use rusqlite::{Connection, OpenFlags}; + +fn ident(label: &str, cap: u64) -> VolumeIdentifiers { + VolumeIdentifiers { + gpt_partition_guid: None, + fs_uuid: None, + label: Some(label.to_owned()), + capacity_bytes: cap, + is_removable: false, + } +} + +#[test] +fn find_or_create_populates_hlc_on_insert_and_update() { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("perima.db"); + + let bus: Arc = Arc::new(NoopBus); + let writer = SqliteWriter::start(&db_path, bus).expect("writer start"); + let reads = ReadPool::open(&db_path).expect("pool open"); + let repo = SqliteVolumeRepository::new(writer.sender(), reads); + + let dev = DeviceId::new(); + let id = repo + .find_or_create(&ident("HLC_TEST", 1_024), dev) + .expect("insert"); + + // Raw read-only Connection to sidestep the adapter + pool so we + // verify the column was actually written. + let ro = Connection::open_with_flags( + &db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .expect("readonly open"); + + let hlc_after_insert: Option = ro + .query_row( + "SELECT hlc FROM volumes WHERE volume_id = ?1 AND deleted_at IS NULL", + [id.0.to_string()], + |row| row.get(0), + ) + .expect("select"); + let inserted_hlc = hlc_after_insert.expect("hlc must be NOT NULL after find_or_create insert"); + assert!(inserted_hlc > 0, "packed HLC must be positive i64"); + + // Second find_or_create on same identifiers → UPDATE path. The + // row's `hlc` must refresh to a strictly greater value (Hlc::now() + // is monotonically non-decreasing; within-ms the counter bumps). + let id2 = repo + .find_or_create(&ident("HLC_TEST", 1_024), dev) + .expect("update"); + assert_eq!(id, id2, "same identifiers must resolve to same VolumeId"); + + let hlc_after_update: Option = ro + .query_row( + "SELECT hlc FROM volumes WHERE volume_id = ?1 AND deleted_at IS NULL", + [id.0.to_string()], + |row| row.get(0), + ) + .expect("select"); + let updated_hlc = hlc_after_update.expect("hlc must be NOT NULL after UPDATE"); + assert!( + updated_hlc > inserted_hlc, + "second find_or_create should refresh hlc to a strictly greater value \ + (got {updated_hlc} <= {inserted_hlc})" + ); + + // Tear down explicitly — drops the writer handle's sender + reaps + // the writer thread cleanly before the tempdir is removed. + drop(repo); + writer.join(); +} diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml index 4ebd785..0877cb0 100644 --- a/crates/desktop/Cargo.toml +++ b/crates/desktop/Cargo.toml @@ -15,7 +15,8 @@ crate-type = ["staticlib", "cdylib", "rlib"] name = "perima_desktop" [dependencies] -perima-core = { path = "../core" } +perima-app = { workspace = true, features = ["specta"] } +perima-core = { path = "../core", features = ["specta"] } perima-db = { path = "../db" } perima-fs = { path = "../fs" } perima-hash = { path = "../hash" } @@ -33,6 +34,7 @@ uuid.workspace = true chrono.workspace = true rayon.workspace = true directories.workspace = true +async-trait.workspace = true tokio.workspace = true tokio-util.workspace = true @@ -50,5 +52,12 @@ tokio.workspace = true # regenerating at test time keeps the fixture in lockstep. image.workspace = true +[features] +default = [] +# WHY: gates the bindings.ts emission so CI can opt in without conflating +# release-build behaviour with debug_assertions. Consumed by lib.rs::run in +# Task 7 (replaces the existing #[cfg(debug_assertions)] guard). +specta-export = [] + [lints] workspace = true diff --git a/crates/desktop/src/commands.rs b/crates/desktop/src/commands.rs index b05ab2a..072b939 100644 --- a/crates/desktop/src/commands.rs +++ b/crates/desktop/src/commands.rs @@ -1,34 +1,70 @@ //! Tauri IPC commands exposed to the frontend. //! -//! WHY per-command DB connections: Tauri's command system runs handlers in a -//! thread-pool. Holding a single shared `rusqlite::Connection` across commands -//! would require `Arc>`, adding contention and lifetime -//! complexity. Opening per-command is cheap under `SQLite` WAL mode — the second -//! `open_and_migrate` call is a no-op migration that returns instantly. +//! After the Batch B Task 9 migration (#69 consolidation), every migrated +//! handler delegates to one of the `UseCase` fields on `AppState.container` +//! via a short `container.xx.execute(cmd).await` call. Pre-existing +//! `_inner` helpers are retained unchanged so `crates/desktop/tests/ +//! commands_test.rs` keeps exercising the underlying logic without +//! constructing `tauri::State` (those helpers re-open per-call connections +//! exactly like the pre-migration code path did). +//! +//! WHY two styles coexist: the `#[tauri::command]` production path is +//! thin-delegation to `container.*.execute`; the `_inner` helpers remain +//! as a test seam until a future batch replaces them with a +//! container-based test harness. +//! +//! WHY wire-mirror types were deleted (Batch D Task 8): `ScanResult`, +//! `FileEntry`, `VolumeEntry`, `TagPayload`, and `SearchHitPayload` were +//! 1:1 mirrors of `perima-core` types. Now that the core types derive +//! `specta::Type`, the handlers return them directly and the mirrors are +//! obsolete. `FileWithMetadataPayload` + `FileWithTagsPayload` are retained +//! (see `crates/desktop/src/payloads.rs`) because they flatten composite +//! `(record, Option)` pairs with no clean core analogue. use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; +use perima_app::{ + EventHandler, FullScan, MetadataCommand, MetadataOutput, ScanCommand, ScanReport, + SearchCommand, SearchOutput, TagCommand, TagFilter, TagOutput, VolumeCommand, VolumeOutput, +}; use perima_core::{ - CoreError, DeviceId, EventBus, FileEvent, LocationStatus, MetadataExtractor, - MetadataRepository, SearchRepository, TagRepository, VolumeId, + AppEvent, CoreError, DeviceId, EventBus, FileEvent, FileLocationRecord, LocationStatus, + MetadataExtractor, MetadataRepository, SearchRepository, Tag, TagRepository, VolumeId, + VolumeRecord, }; -use perima_db::{SqliteFileRepository, SqliteVolumeRepository, open_and_migrate}; +use perima_db::{ReadPool, SqliteFileRepository, SqliteVolumeRepository, SqliteWriter}; use perima_fs::{DebouncedWatcher, WalkdirScanner}; use perima_hash::Blake3Service; use perima_media::{ CompositeExtractor, ImageExtractor, MetadataQueue, ThumbnailGenerator, VideoExtractor, }; use rayon::prelude::*; -use serde::Serialize; use tokio_util::sync::CancellationToken; -use crate::events::TauriEventEmitter; -use crate::payloads::{FileWithMetadataPayload, FileWithTagsPayload, SearchHitPayload, TagPayload}; +use crate::payloads::{FileWithMetadataPayload, FileWithTagsPayload}; use crate::state::{AppState, WatcherState}; +/// Parses a string into a `VolumeId`, wrapping a `Uuid` parse failure +/// as `CoreError::Internal`. +/// +/// WHY a typed `CoreError::InvalidId` would be cleaner — that's the +/// post-Batch-D follow-up tracked in the spec §10 #1 list. `Internal` +/// is the pragmatic v1 shape. +fn parse_volume_id(s: &str) -> Result { + uuid::Uuid::parse_str(s) + .map(VolumeId) + .map_err(|e| CoreError::Internal(format!("bad volume UUID: {e}"))) +} + +/// Parses a string into a tag `Uuid`, wrapping a parse failure as +/// `CoreError::Internal`. See [`parse_volume_id`] for the typing +/// rationale. +fn parse_tag_id(s: &str) -> Result { + uuid::Uuid::parse_str(s).map_err(|e| CoreError::Internal(format!("bad tag UUID: {e}"))) +} + /// Maximum time `scan` waits for the metadata worker to drain after /// the walk loop completes. /// @@ -37,57 +73,90 @@ use crate::state::{AppState, WatcherState}; /// that a stuck extractor cannot hang the Tauri command indefinitely. const METADATA_DRAIN_TIMEOUT: Duration = Duration::from_secs(30); +/// Production stub `EventBus` for the `_inner` test-helper writers. +/// +/// WHY a single module-level definition: `clippy::items_after_statements` +/// fires on inline `struct NoopBus` declarations inside the `_inner` +/// helpers. Hoisting once here removes the lint and consolidates the +/// shell-local stub. The shared `perima_db::test_utils::NoopBus` is +/// gated behind the `test-utils` feature and not available to production +/// builds — see #119/#125 for the long-term consolidation issue. +struct LocalNoopBus; +impl EventBus for LocalNoopBus { + fn emit(&self, _: &AppEvent) -> Result<(), CoreError> { + Ok(()) + } +} + // --------------------------------------------------------------------------- -// Event handlers (duplicated from crates/cli/src/cmd/watch.rs) +// Event handlers // -// WHY duplicated: `crates/cli` is a binary crate shell; `crates/desktop` -// cannot depend on it (that would create an unwanted coupling between two -// app shells and pull in CLI-only dependencies). Consolidating these into a -// shared crate (`crates/watch-handlers` or similar) is deferred to post-v1 -// once the API has stabilised. The duplication is intentional and small. +// WHY `DbEventHandler` stays shell-local: it touches +// `SqliteFileRepository` (a `crates/db` concrete adapter) via +// `Arc`. Hoisting it into `perima-app` would +// force the app crate to depend on a concrete adapter, violating the +// ports-and-adapters boundary. `LogEventHandler` — previously the +// duplicate sibling in this section — was hoisted to +// `perima_app::telemetry` in Task 10 because it has zero adapter +// coupling. The shell-local `TauriEventHandler` (Batch E Task 11) +// replaces the old `TauriEventEmitter` + `EventBus` wiring with the +// `EventHandler` trait pattern — exactly one `Bus::new` call in the +// codebase, inside `crates/app/src/container.rs::AppContainer::new`. // --------------------------------------------------------------------------- -/// Fans out events to multiple [`EventBus`] implementations. -/// -/// Individual handler errors are logged but do not abort the fan-out — -/// all registered handlers always fire regardless of prior failures. +/// Updates the database in response to filesystem events. /// -/// WHY lives in commands.rs (not core): `CompositeEventBus` uses -/// `tracing::warn!` which requires the `tracing` crate. `crates/core` -/// deliberately has zero framework dependencies. -struct CompositeEventBus { - handlers: Vec>, +/// WHY `Arc`: `EventHandler` requires `Send + 'static`. +/// `SqliteFileRepository` uses interior mutability (flume sender + r2d2 pool), +/// satisfying both. `Arc` gives shared ownership without cloning the heavy repo. +pub struct DbEventHandler { + repo: Arc, + device: DeviceId, } -impl CompositeEventBus { - /// Construct from a list of handlers. - fn new(handlers: Vec>) -> Self { - Self { handlers } +impl std::fmt::Debug for DbEventHandler { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // WHY manual: `Arc` lacks `Debug`. + // Print the type name + device for tracing-instrument span context. + f.debug_struct("DbEventHandler") + .field("device", &self.device) + .finish_non_exhaustive() } } -impl EventBus for CompositeEventBus { - fn emit(&self, event: &FileEvent) -> Result<(), CoreError> { - for h in &self.handlers { - if let Err(e) = h.emit(event) { - tracing::warn!(error = %e, "event handler failed"); - } - } - Ok(()) +impl DbEventHandler { + /// Construct a [`DbEventHandler`] bound to the given file repository + /// and device. + /// + /// WHY a `new` constructor: `lib.rs::setup` builds the handler before + /// `AppContainer::new` wraps it into the single `Bus`. + /// Keeping the struct fields private + exposing `new` preserves + /// encapsulation across the crate boundary. + #[must_use] + pub const fn new(repo: Arc, device: DeviceId) -> Self { + Self { repo, device } } } -/// Updates the database in response to filesystem events. -/// -/// WHY `Arc`: `EventBus` requires `Send + Sync`. -/// `SqliteFileRepository` uses `Mutex` internally, satisfying both. -struct DbEventHandler { - repo: Arc, - device: DeviceId, +#[async_trait::async_trait] +impl EventHandler for DbEventHandler { + fn name(&self) -> &'static str { + "db_event_handler" + } + + async fn handle(&mut self, event: AppEvent) { + // Desktop DB handler only acts on FileEvents — ScanCompleted and + // IndexInvalidated are handled by TauriEventHandler for the frontend. + if let AppEvent::File(file_event) = event + && let Err(e) = self.record_file_event(&file_event) + { + tracing::warn!(error = %e, "failed to record file event"); + } + } } -impl EventBus for DbEventHandler { - fn emit(&self, event: &FileEvent) -> Result<(), CoreError> { +impl DbEventHandler { + fn record_file_event(&self, event: &FileEvent) -> Result<(), CoreError> { match event { FileEvent::Created { path, .. } => { // WHY: we do not hash new files in watch mode. Hashing requires @@ -135,101 +204,15 @@ impl EventBus for DbEventHandler { } } -/// Logs every filesystem event at INFO level. -struct LogEventHandler; - -impl EventBus for LogEventHandler { - fn emit(&self, event: &FileEvent) -> Result<(), CoreError> { - tracing::info!(?event, "file event"); - Ok(()) - } -} - // --------------------------------------------------------------------------- -// Wire-types -// -// WHY wrapper structs: `FileLocationRecord` and `VolumeRecord` live in -// `perima-core` which has zero framework dependencies. Adding `specta` to -// core would violate that constraint. Thin wrappers here carry `specta::Type` -// without touching the core domain types. +// Composite payload types for commands that join multiple records // --------------------------------------------------------------------------- - -/// Scan statistics returned to the frontend. -#[derive(Debug, Clone, Copy, Serialize, specta::Type)] -pub struct ScanResult { - /// Total files encountered (new + existing + errors). - pub total: u64, - /// Files newly inserted into the index. - pub new: u64, - /// Files already indexed (unchanged or updated). - pub existing: u64, - /// Files skipped due to hash or persist errors. - pub errors: u64, -} - -/// Wire-type for a file location record, safe to cross the IPC boundary. -#[derive(Debug, Clone, Serialize, specta::Type)] -pub struct FileEntry { - /// BLAKE3-256 content hash as hex string. - pub hash: String, - /// File size in bytes. - pub size: u64, - /// Volume UUID. - pub volume_id: String, - /// Relative path within the volume. - pub relative_path: String, - /// Location status string. - pub status: String, - /// ISO 8601 UTC timestamp of first sighting. - pub first_seen: String, -} - -impl From for FileEntry { - fn from(r: perima_core::FileLocationRecord) -> Self { - Self { - hash: r.hash.to_hex(), - size: r.size.0, - volume_id: r.volume_id.0.to_string(), - relative_path: r.relative_path.as_str().to_owned(), - status: format!("{:?}", r.status), - first_seen: r.first_seen, - } - } -} - -/// Wire-type for a volume record, safe to cross the IPC boundary. -#[derive(Debug, Clone, Serialize, specta::Type)] -pub struct VolumeEntry { - /// Volume UUID. - pub id: String, - /// Volume label if any. - pub label: Option, - /// Total capacity in bytes. - pub capacity_bytes: u64, - /// Whether the OS reports this as removable. - pub is_removable: bool, - /// Mount paths on this machine as strings. - pub mounts_on_this_machine: Vec, - /// ISO 8601 UTC timestamp of last sighting. - pub last_seen: String, -} - -impl From for VolumeEntry { - fn from(r: perima_core::VolumeRecord) -> Self { - Self { - id: r.id.0.to_string(), - label: r.label, - capacity_bytes: r.capacity_bytes, - is_removable: r.is_removable, - mounts_on_this_machine: r - .mounts_on_this_machine - .into_iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(), - last_seen: r.last_seen, - } - } -} +// +// WHY here instead of payloads.rs: `FileWithMetadataPayload` and +// `FileWithTagsPayload` are retained composites (spec §8 #6). They live in +// `payloads.rs`; this module imports them for use in command bodies. +// The 1:1 wire-mirror types (`ScanResult`, `FileEntry`, `VolumeEntry`, +// `TagPayload`, `SearchHitPayload`) were deleted in Batch D Task 8. /// Callback type for the sentinel migration; factored out to avoid /// `clippy::type_complexity` on the `run_scan_live` signature. @@ -252,12 +235,13 @@ pub type OnPersistFn<'a> = /// Walk `path`, hash every file, and persist results to the database. /// -/// Returns a [`ScanResult`] with per-category file counts. When `dry_run` is -/// true, hashing still occurs but nothing is written to the DB. +/// Returns a [`ScanReport`] with per-category file counts and timing. +/// When `dry_run` is true, hashing still occurs but nothing is written +/// to the DB. /// /// # Errors -/// Returns a `String` description of any [`perima_core::CoreError`] that -/// surfaces during volume detection, walking, hashing, or persistence. +/// Returns a [`CoreError`] if volume detection, walking, hashing, or +/// persistence fails. // WHY allow: Tauri requires `State<'_, T>` and `String` params to be owned. // The lint fires because we immediately borrow through Deref, but we cannot // change the signature — Tauri's command dispatcher owns these values. @@ -268,20 +252,41 @@ pub async fn scan( path: String, dry_run: bool, state: tauri::State<'_, AppState>, -) -> Result { - let root = PathBuf::from(&path); - // WHY pass metadata_repo through: phase 4's metadata queue + - // thumbnail pipeline was wired into the CLI scan but the desktop - // scan shipped without it — the desktop app would index files but - // never run extractors or generate thumbnails. See - // utof/perima#15 HIGH #11a. - let metadata_repo: Arc = - Arc::clone(&state.metadata_repo) as Arc; - let data_dir = state.data_dir.clone(); - let device_id = state.device_id; - run_scan_inner_with_metadata(&root, dry_run, &data_dir, device_id, Some(metadata_repo)) - .await - .map_err(|e| e.to_string()) +) -> Result { + let cmd = ScanCommand::Full(FullScan { + path: PathBuf::from(&path), + device_id: state.device_id, + // WHY `with_metadata = !dry_run`: preserves the pre-migration + // desktop default — every non-dry scan spawns the metadata queue. + with_metadata: !dry_run, + dry_run, + // WHY no_wait_metadata = false: the Tauri command blocks until + // the bounded drain completes so the frontend sees a fully + // populated metadata + thumbnails set by the time `scan` returns. + no_wait_metadata: false, + // WHY no_thumbnails = false: the desktop UI grid depends on + // WebP thumbnails in `/thumbnails/**`; disabling them + // would yield an empty grid. + no_thumbnails: false, + // WHY fresh `CancellationToken::new()`: the desktop has no + // Ctrl-C handler today (users close the window). A cancel RPC + // is future work (Batch E); for now we hand the UseCase a + // never-cancelled token. + cancel: CancellationToken::new(), + on_persist: None, + }); + let report = state.container.scan.execute(cmd).await?; + + // WHY write_manifest stays in the shell: per spec §2 IN, `crates/app` + // deliberately does not depend on `perima-db`. The `ScanReport` + // surfaces `volume_mount` + `manifest_files` for the shell to wire + // manifest persistence; the CLI does the same in `crates/cli/src/ + // cmd/scan.rs::run`. + if let Some((vol_id, mount)) = report.volume_mount.as_ref() { + perima_db::manifest::write_manifest(mount, *vol_id, &report.manifest_files)?; + } + + Ok(report) } /// Inner scan logic extracted for testability without a live Tauri state. @@ -292,6 +297,12 @@ pub async fn scan( /// [`run_scan_inner_with_metadata`] to exercise the full extract + thumbnail /// pipeline. /// +/// WHY retained post-Batch-B: `crates/desktop/tests/commands_test.rs` +/// references this function directly (search for `run_scan_inner`). The +/// production `scan` command delegates through `AppContainer` instead; +/// this helper keeps the test seam alive until a container-based test +/// harness lands in a follow-up batch. +/// /// # Errors /// Returns [`perima_core::CoreError`] on filesystem, volume detection, hash, /// or database failures. @@ -300,7 +311,7 @@ pub async fn run_scan_inner( dry_run: bool, data_dir: &Path, device_id: DeviceId, -) -> Result { +) -> Result { run_scan_inner_with_metadata(root, dry_run, data_dir, device_id, None).await } @@ -315,6 +326,10 @@ pub async fn run_scan_inner( /// so generated WebP files live under `/thumbnails/...` — /// the same directory the Tauri asset protocol scope exposes. /// +/// WHY retained post-Batch-B: see `run_scan_inner`. This helper is +/// referenced directly by the `desktop_scan_populates_metadata_and_thumbnails` +/// regression test. +/// /// # Errors /// Returns [`perima_core::CoreError`] on filesystem, volume detection, hash, /// or database failures. @@ -324,36 +339,42 @@ pub async fn run_scan_inner_with_metadata( data_dir: &Path, device_id: DeviceId, metadata_repo: Option>, -) -> Result { +) -> Result { validate_root(root)?; // WHY: routes through perima_fs::platform_path::canonicalize — the single // source of truth for the #[cfg(windows)] dunce / std fallback. let canonical_root = - perima_fs::platform_path::canonicalize(root).map_err(perima_core::CoreError::Io)?; + perima_fs::platform_path::canonicalize(root).map_err(perima_core::CoreError::from)?; let scanner = WalkdirScanner::new(); let hasher = Blake3Service::new(); - // WHY AtomicBool(false): no signal handler in the desktop backend. - // The desktop user closes the window rather than issuing Ctrl-C. - // A proper cancellation channel will be introduced in phase 3 when the + // WHY fresh `CancellationToken`: no signal handler in the desktop + // backend (users close the window rather than issuing Ctrl-C). + // A proper cancellation channel will be introduced when the // file-watcher IPC arrives and we need a cancel RPC. - let never_cancel = Arc::new(AtomicBool::new(false)); + let never_cancel = CancellationToken::new(); if dry_run { return run_scan_dry(&scanner, &hasher, &canonical_root, &never_cancel); } let db_path = data_dir.join("perima.db"); - // WHY three opens: SqliteFileRepository, SqliteVolumeRepository, and the - // sentinel repo each take an owned Connection. WAL mode makes the extra - // opens instant once migrations have run on the first connection. - let file_conn = open_and_migrate(&db_path)?; - let vol_conn = open_and_migrate(&db_path)?; - let sentinel_conn = open_and_migrate(&db_path)?; - - let file_repo = SqliteFileRepository::new(file_conn); - let vol_repo = SqliteVolumeRepository::new(vol_conn); - let sentinel_repo = SqliteFileRepository::new(sentinel_conn); + // WHY a self-contained writer+pool here (test-only seam): this + // helper exists purely for `crates/desktop/tests/commands_test.rs` + // to exercise scan logic without constructing `tauri::State`. Its + // production peer (the `#[tauri::command] scan` handler) delegates + // to `AppContainer.volume` via `state.container`. The writer + // handle is dropped at end of scope — its `Sender` is held via + // `vol_repo` + `file_repo` + `sentinel_repo` for the duration of + // this function call. + let writer = SqliteWriter::start(&db_path, Arc::new(LocalNoopBus))?; + let reads = ReadPool::open(&db_path)?; + + // WHY clone `reads` for each adapter: `ReadPool` is cheap to + // [`Clone`] (inner `r2d2::Pool` is `Arc`-backed). + let file_repo = SqliteFileRepository::new(writer.sender(), reads.clone()); + let vol_repo = SqliteVolumeRepository::new(writer.sender(), reads.clone()); + let sentinel_repo = SqliteFileRepository::new(writer.sender(), reads); let on_persist = |path: &perima_core::MediaPath, volume: VolumeId, dev: DeviceId| { if let Err(e) = sentinel_repo.migrate_sentinel_row(path, volume, dev) { @@ -369,7 +390,7 @@ pub async fn run_scan_inner_with_metadata( let thumbnailer: Arc = Arc::new(ThumbnailGenerator::new(data_dir.to_path_buf())); - run_scan_live( + let result = run_scan_live( &scanner, &hasher, &file_repo, @@ -381,32 +402,71 @@ pub async fn run_scan_inner_with_metadata( metadata_repo, thumbnailer, ) - .await + .await; + + // WHY drop ALL sender-holding values before `writer.join()`: + // `SqliteWriterHandle::join` waits for the writer thread to exit, + // which only happens when ALL `Sender` clones drop and + // the channel closes. `file_repo`, `vol_repo`, and `sentinel_repo` + // each hold one sender clone via `writer.sender()` calls above; + // `on_persist` borrows `sentinel_repo`. Dropping only `vol_repo` + // (the previous bug) left two senders alive → writer thread parked + // in `flume::Receiver::recv` forever → `pthread_join` on writer + // hangs the test. Reproduced 2026-04-23 with gdb backtrace + // (Thread 18 → futex on writer's TID; Thread 17 → flume recv). + // The `on_persist` closure is `Copy` (only borrows `&sentinel_repo`) + // so it doesn't need explicit `drop`; its borrow on `sentinel_repo` + // ends with the last use inside `run_scan_live` above. + drop(file_repo); + drop(vol_repo); + drop(sentinel_repo); + writer.join(); + + result } /// List indexed file locations, optionally filtered by volume. /// -/// Returns up to `limit` [`FileEntry`] records ordered by relative path. +/// Returns up to `limit` [`FileLocationRecord`] records ordered by +/// relative path. /// /// # Errors -/// Returns a `String` description of any [`perima_core::CoreError`]. +/// Returns a [`CoreError`] on volume UUID parse failure or database errors. // WHY allow: same reason as `scan` — Tauri owns `State` and `Option` params. #[allow(clippy::needless_pass_by_value)] #[tauri::command] #[specta::specta] -pub fn list_files( +pub async fn list_files( limit: u32, volume: Option, state: tauri::State<'_, AppState>, -) -> Result, String> { - let volume_id = volume - .map(|v| { - uuid::Uuid::parse_str(&v) - .map(VolumeId) - .map_err(|e| format!("bad volume UUID: {e}")) +) -> Result, CoreError> { + let volume_id = volume.as_deref().map(parse_volume_id).transpose()?; + + let out = state + .container + .metadata + .execute(MetadataCommand::ListFiles { + limit: Some(limit), + offset: None, + device: state.device_id, }) - .transpose()?; - list_files_inner(&state.data_dir, limit, volume_id).map_err(|e| e.to_string()) + .await?; + let MetadataOutput::Files(records) = out else { + return Err(CoreError::Internal( + "ListFiles returned non-Files output".into(), + )); + }; + + // WHY post-filter by volume in the shell: `MetadataCommand::ListFiles` + // does not yet accept a volume filter (Batch B kept its surface + // narrow); filtering in memory after the UseCase returns matches the + // CLI `ls.rs` pattern for the same constraint. + let filtered: Vec = records + .into_iter() + .filter(|r| volume_id.is_none_or(|v| r.volume_id == v)) + .collect(); + Ok(filtered) } /// Inner list-files logic extracted for testability without a live Tauri state. @@ -420,13 +480,19 @@ pub fn list_files_inner( data_dir: &Path, limit: u32, volume_id: Option, -) -> Result, perima_core::CoreError> { +) -> Result, perima_core::CoreError> { let db_path = data_dir.join("perima.db"); - let conn = open_and_migrate(&db_path)?; - let repo = SqliteFileRepository::new(conn); - let records = - perima_core::FileRepository::list_file_locations(&repo, limit as usize, volume_id)?; - Ok(records.into_iter().map(FileEntry::from).collect()) + // WHY local writer+pool: this helper is a test-seam; it does not have + // access to the AppContainer / Tauri state. The writer is dropped at + // end of scope; the read pool keeps its connection alive until after + // the query completes. + let writer = SqliteWriter::start(&db_path, Arc::new(LocalNoopBus))?; + let reads = ReadPool::open(&db_path)?; + let repo = SqliteFileRepository::new(writer.sender(), reads); + // WHY explicit drop: close the writer sender before returning so + // the writer thread can exit cleanly if no other senders exist. + drop(writer); + perima_core::FileRepository::list_file_locations(&repo, limit as usize, volume_id) } /// List indexed file locations joined with any extracted media metadata. @@ -437,7 +503,7 @@ pub fn list_files_inner( /// treat that as "pending extraction", not "no metadata exists". /// /// # Errors -/// Returns a `String` description of any [`perima_core::CoreError`]. +/// Returns a [`CoreError`] on volume UUID parse failure or database errors. // WHY allow: same reason as `scan` — Tauri owns `State` and `Option` params. #[allow(clippy::needless_pass_by_value)] #[tauri::command] @@ -446,16 +512,33 @@ pub async fn list_files_with_metadata( limit: u32, volume: Option, state: tauri::State<'_, AppState>, -) -> Result, String> { - let volume_id = volume - .map(|v| { - uuid::Uuid::parse_str(&v) - .map(VolumeId) - .map_err(|e| format!("bad volume UUID: {e}")) +) -> Result, CoreError> { + let volume_id = volume.as_deref().map(parse_volume_id).transpose()?; + + let out = state + .container + .metadata + .execute(MetadataCommand::ListFilesWithMetadata { + limit: Some(limit), + offset: None, + device: state.device_id, }) - .transpose()?; - list_files_with_metadata_inner(state.metadata_repo.as_ref(), limit, volume_id) - .map_err(|e| e.to_string()) + .await?; + let MetadataOutput::FilesWithMetadata(rows) = out else { + return Err(CoreError::Internal( + "ListFilesWithMetadata returned non-FilesWithMetadata output".into(), + )); + }; + + // WHY post-filter by volume: see `list_files` — the `MetadataCommand` + // variants don't expose a volume filter yet. Kept symmetric with + // `list_files` for maintainability. + let filtered: Vec = rows + .into_iter() + .filter(|(loc, _)| volume_id.is_none_or(|v| loc.volume_id == v)) + .map(FileWithMetadataPayload::from) + .collect(); + Ok(filtered) } /// Inner list-files-with-metadata logic extracted for testability without @@ -485,13 +568,27 @@ where /// List all known volumes with their current mount paths on this machine. /// /// # Errors -/// Returns a `String` description of any [`perima_core::CoreError`]. +/// Returns a [`CoreError`] on database failures. // WHY allow: Tauri requires `State<'_, T>` to be owned. See `scan` for rationale. #[allow(clippy::needless_pass_by_value)] #[tauri::command] #[specta::specta] -pub fn list_volumes(state: tauri::State<'_, AppState>) -> Result, String> { - list_volumes_inner(&state.data_dir, state.device_id).map_err(|e| e.to_string()) +pub async fn list_volumes( + state: tauri::State<'_, AppState>, +) -> Result, CoreError> { + let out = state + .container + .volume + .execute(VolumeCommand::List { + device: state.device_id, + }) + .await?; + let VolumeOutput::Volumes(records) = out else { + return Err(CoreError::Internal( + "VolumeCommand::List returned non-Volumes output".into(), + )); + }; + Ok(records) } /// Inner list-volumes logic extracted for testability without a live Tauri state. @@ -504,12 +601,25 @@ pub fn list_volumes(state: tauri::State<'_, AppState>) -> Result Result, perima_core::CoreError> { +) -> Result, perima_core::CoreError> { + // WHY a self-contained writer+pool here (test-only seam): same + // rationale as `run_scan_inner_with_metadata`. Production + // `#[tauri::command] list_volumes` delegates to + // `state.container.volume.execute(List)`. + struct NoopBus; + impl EventBus for NoopBus { + fn emit(&self, _: &AppEvent) -> Result<(), CoreError> { + Ok(()) + } + } let db_path = data_dir.join("perima.db"); - let conn = open_and_migrate(&db_path)?; - let repo = SqliteVolumeRepository::new(conn); + let writer = SqliteWriter::start(&db_path, Arc::new(NoopBus))?; + let reads = ReadPool::open(&db_path)?; + let repo = SqliteVolumeRepository::new(writer.sender(), reads); let records = perima_core::VolumeRepository::list(&repo, device_id)?; - Ok(records.into_iter().map(VolumeEntry::from).collect()) + drop(repo); + writer.join(); + Ok(records) } // --------------------------------------------------------------------------- @@ -518,13 +628,16 @@ pub fn list_volumes_inner( /// Start watching `path` for filesystem changes. /// -/// Validates the path, detects the volume, opens the database, cancels any -/// prior watcher, then starts a new [`DebouncedWatcher`] that emits -/// `"file-event"` Tauri events and DB updates on every filesystem change. +/// Validates the path, detects the volume, then starts a new +/// [`DebouncedWatcher`] that forwards every filesystem event to the +/// shared `container.events` bus. The bus was assembled once at +/// `lib.rs::setup` with the `LogEventHandler`, `DbEventHandler`, and +/// `TauriEventHandler` already wired — no second `Bus` is +/// constructed here (spec §4 acceptance). /// /// # Errors -/// Returns a `String` if the path is invalid, volume detection fails, or the -/// database cannot be opened or migrated. +/// Returns a [`CoreError`] if the path is invalid, volume detection fails, +/// or the database cannot be opened or migrated. // WHY allow needless_pass_by_value: Tauri's command dispatcher owns `State` // and `String` params; the signature cannot be changed. #[allow(clippy::needless_pass_by_value)] @@ -532,53 +645,44 @@ pub fn list_volumes_inner( #[specta::specta] pub async fn start_watch( path: String, - app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, watcher_state: tauri::State<'_, WatcherState>, -) -> Result<(), String> { +) -> Result<(), CoreError> { let root = PathBuf::from(&path); - validate_root(&root).map_err(|e| e.to_string())?; - let canonical_root = - perima_fs::platform_path::canonicalize(&root).map_err(|e| format!("canonicalize: {e}"))?; - - let detected = perima_fs::detect_volume(&canonical_root).map_err(|e| e.to_string())?; - let db_path = state.data_dir.join("perima.db"); + validate_root(&root)?; + // WHY direct ?: From for CoreError lowers to the typed + // Io { kind, message } variant, preserving io::ErrorKind for the + // frontend pattern-match path (E11). + let canonical_root = perima_fs::platform_path::canonicalize(&root)?; + + // Resolve or create the volume record for this mount. + // + // WHY delegate to `state.container.volumes` for both `find_or_create` + // and `record_mount` post-Batch-C Task 2: the writer actor owns the + // sole writable connection. `find_or_create` still has no UseCase + // surface (scan/watch startup concern); the container exposes the + // raw `Arc` field for this purpose. + let detected = perima_fs::detect_volume(&canonical_root)?; let device_id = state.device_id; - // WHY two connections: SqliteVolumeRepository and SqliteFileRepository - // each take an owned Connection. Under WAL mode a second open is instant. - let vol_conn = open_and_migrate(&db_path).map_err(|e| e.to_string())?; - let file_conn = open_and_migrate(&db_path).map_err(|e| e.to_string())?; - - let vol_repo = SqliteVolumeRepository::new(vol_conn); - let volume_id = - perima_core::VolumeRepository::find_or_create(&vol_repo, &detected.identifiers, device_id) - .map_err(|e| e.to_string())?; - perima_core::VolumeRepository::record_mount( - &vol_repo, - volume_id, - device_id, - &detected.mount_point, - ) - .map_err(|e| e.to_string())?; - drop(vol_repo); - - let file_repo = Arc::new(SqliteFileRepository::new(file_conn)); - - let db_handler: Arc = Arc::new(DbEventHandler { - repo: Arc::clone(&file_repo), - device: device_id, - }); - let tauri_emitter: Arc = Arc::new(TauriEventEmitter { - app_handle: app_handle.clone(), - }); - let log_handler: Arc = Arc::new(LogEventHandler); - - let composite = Arc::new(CompositeEventBus::new(vec![ - db_handler, - tauri_emitter, - log_handler, - ])); + let volume_id = state + .container + .volumes + .find_or_create(&detected.identifiers, device_id)?; + + // WHY delegate mount-recording to the VolumeUseCase: this is the + // single call the UseCase's `RecordMount` variant was built for; + // routing it through the container keeps the event-bus emission + // contract consistent once Batch E wires volume events. + state + .container + .volume + .execute(VolumeCommand::RecordMount { + volume_id, + path: detected.mount_point.clone(), + device: device_id, + }) + .await?; // Cancel any existing watcher before starting a new one. { @@ -595,17 +699,23 @@ pub async fn start_watch( let cancel = CancellationToken::new(); + // WHY `Arc::clone(&state.container.events)`: the container's + // `events` field is the already-composed `Bus` built at setup time + // with every shell handler (Log, Db, Tauri). DebouncedWatcher takes + // an `Arc`; cloning the Arc avoids a second bus + // construction in this shell. + let bus: Arc = Arc::clone(&state.container.events); + // WHY 1 s production debounce: short enough for responsive feedback, // long enough to coalesce rapid saves (e.g. editors that write-then-chmod). let watcher = DebouncedWatcher::start( std::slice::from_ref(&canonical_root), &canonical_root, volume_id, - composite, + bus, cancel.clone(), Duration::from_secs(1), - ) - .map_err(|e| e.to_string())?; + )?; { let mut cancel_guard = watcher_state.cancel.lock().await; @@ -631,7 +741,7 @@ pub async fn start_watch( #[allow(clippy::needless_pass_by_value)] #[tauri::command] #[specta::specta] -pub async fn stop_watch(watcher_state: tauri::State<'_, WatcherState>) -> Result<(), String> { +pub async fn stop_watch(watcher_state: tauri::State<'_, WatcherState>) -> Result<(), CoreError> { { let mut cancel_guard = watcher_state.cancel.lock().await; if let Some(token) = cancel_guard.take() { @@ -654,7 +764,7 @@ pub async fn stop_watch(watcher_state: tauri::State<'_, WatcherState>) -> Result #[allow(clippy::needless_pass_by_value)] #[tauri::command] #[specta::specta] -pub async fn is_watching(watcher_state: tauri::State<'_, WatcherState>) -> Result { +pub async fn is_watching(watcher_state: tauri::State<'_, WatcherState>) -> Result { let inner_guard = watcher_state.inner.lock().await; Ok(inner_guard.is_some()) } @@ -666,13 +776,19 @@ pub async fn is_watching(watcher_state: tauri::State<'_, WatcherState>) -> Resul /// List all active (non-deleted) tags, sorted by name. /// /// # Errors -/// Returns a `String` description of any [`perima_core::CoreError`]. +/// Returns a [`CoreError`] on database failures. // WHY allow: Tauri requires `State<'_, T>` to be owned. See `scan` for rationale. #[allow(clippy::needless_pass_by_value)] #[tauri::command] #[specta::specta] -pub fn list_tags(state: tauri::State<'_, AppState>) -> Result, String> { - list_tags_inner(state.tag_repo.as_ref()).map_err(|e| e.to_string()) +pub async fn list_tags(state: tauri::State<'_, AppState>) -> Result, CoreError> { + let out = state.container.tag.execute(TagCommand::List).await?; + let TagOutput::Tags(tags) = out else { + return Err(CoreError::Internal( + "TagCommand::List returned non-Tags output".into(), + )); + }; + Ok(tags) } /// Inner list-tags logic extracted for testability without a live Tauri state. @@ -684,30 +800,53 @@ pub fn list_tags(state: tauri::State<'_, AppState>) -> Result, S /// Returns [`perima_core::CoreError`] on any repository failure. pub fn list_tags_inner( tag_repo: &T, -) -> Result, perima_core::CoreError> { - let tags = tag_repo.list_tags()?; - Ok(tags.into_iter().map(TagPayload::from).collect()) +) -> Result, perima_core::CoreError> { + tag_repo.list_tags() } /// Attach a tag to a file by content hash (upsert the tag first, then attach). /// -/// Returns the [`TagPayload`] so the frontend can immediately display it +/// Returns the [`Tag`] so the frontend can immediately display it /// without a round-trip `list_tags` call. /// /// # Errors -/// Returns a `String` if the hash is malformed, the tag name is invalid, or -/// the repository fails. +/// Returns a [`CoreError`] if the hash is malformed, the tag name is +/// invalid, or the repository fails. // WHY allow: Tauri owns the `State`, `String` params. #[allow(clippy::needless_pass_by_value)] #[tauri::command] #[specta::specta] -pub fn attach_tag( +pub async fn attach_tag( hash: String, tag_name: String, state: tauri::State<'_, AppState>, -) -> Result { - attach_tag_inner(state.tag_repo.as_ref(), &hash, &tag_name, state.device_id) - .map_err(|e| e.to_string()) +) -> Result { + // WHY parse the hash + resolve tag through the shell-side + // `state.tag_repo` instead of adding a "return the tag" variant to + // `TagCommand::Attach`: the frontend currently expects the full + // [`Tag`] (id + name + first_seen) back from `attach_tag`. + // The `TagUseCase::Attach` response is `TagOutput::Attached(u64)` + // — just a rows-changed count. Rather than widen the UseCase + // output mid-batch, we do the attach via the container and then + // read the freshly-upserted tag via the legacy `state.tag_repo` + // handle. A future follow-up ("Attached { tag: Tag }") removes + // this second lookup. + let parsed_hash = perima_core::BlakeHash::parse_hex(&hash)?; + state + .container + .tag + .execute(TagCommand::Attach { + hash: parsed_hash, + name: tag_name.clone(), + device: state.device_id, + }) + .await?; + + // Look up the freshly-upserted tag so the frontend gets the full + // payload. `upsert_tag` is idempotent — calling it here returns the + // same row the UseCase just wrote. + let tag = state.tag_repo.upsert_tag(&tag_name, state.device_id)?; + Ok(tag) } /// Inner attach-tag logic extracted for testability without a live Tauri state. @@ -723,7 +862,7 @@ pub fn attach_tag_inner( hash_hex: &str, tag_name: &str, device: DeviceId, -) -> Result { +) -> Result { // WHY direct `?` propagation: `parse_hex` already returns the typed // `CoreError::InvalidHash` variant. Wrapping it in `Internal` would // discard that signal for future HTTP/FFI adapters that match on @@ -731,25 +870,51 @@ pub fn attach_tag_inner( let hash = perima_core::BlakeHash::parse_hex(hash_hex)?; let tag = tag_repo.upsert_tag(tag_name, device)?; tag_repo.attach(&hash, tag.id, device)?; - Ok(TagPayload::from(tag)) + Ok(tag) } /// Remove a tag from a file by content hash + tag UUID. /// /// # Errors -/// Returns a `String` if either the hash or tag UUID is malformed, or if the -/// repository fails. +/// Returns a [`CoreError`] if either the hash or tag UUID is malformed, or +/// if the repository fails. // WHY allow: Tauri owns the `State`, `String` params. #[allow(clippy::needless_pass_by_value)] #[tauri::command] #[specta::specta] -pub fn detach_tag( +pub async fn detach_tag( hash: String, tag_id: String, state: tauri::State<'_, AppState>, -) -> Result<(), String> { - detach_tag_inner(state.tag_repo.as_ref(), &hash, &tag_id, state.device_id) - .map_err(|e| e.to_string()) +) -> Result<(), CoreError> { + // WHY resolve tag_id -> tag name in the shell: the + // `TagCommand::Detach` variant takes `{ hash, name, device }` — it + // looks up the tag by name via `upsert_tag`, the same idempotent + // path used pre-Batch-B. The frontend still passes the tag's UUID + // (string) because the `Tag` it already has in hand surfaces + // `id`, not `name`. We resolve id → name via the legacy + // `state.tag_repo` handle. A future "Detach by id" variant on the + // UseCase obsoletes this lookup. + let parsed_hash = perima_core::BlakeHash::parse_hex(&hash)?; + let parsed_id = parse_tag_id(&tag_id)?; + + let tags = state.tag_repo.list_tags()?; + let tag_name = tags + .into_iter() + .find(|t| t.id == parsed_id) + .map(|t| t.name) + .ok_or_else(|| CoreError::Internal(format!("tag not found: {parsed_id}")))?; + + state + .container + .tag + .execute(TagCommand::Detach { + hash: parsed_hash, + name: tag_name, + device: state.device_id, + }) + .await?; + Ok(()) } /// Inner detach-tag logic extracted for testability without a live Tauri state. @@ -769,8 +934,7 @@ pub fn detach_tag_inner( // WHY `Internal` wrap on `Uuid::parse_str`: `CoreError` has no // dedicated UUID variant; `Internal` is the pragmatic fallback // until a validation-error variant is introduced. - let tag_id = uuid::Uuid::parse_str(tag_id_str) - .map_err(|e| perima_core::CoreError::Internal(format!("bad tag UUID: {e}")))?; + let tag_id = parse_tag_id(tag_id_str)?; tag_repo.detach(&hash, tag_id, device)?; Ok(()) } @@ -781,31 +945,41 @@ pub fn detach_tag_inner( /// in a second query and merged in Rust. /// /// # Errors -/// Returns a `String` description of any [`perima_core::CoreError`] or -/// UUID parse failure. +/// Returns a [`CoreError`] on database failures or UUID parse failure. // WHY allow: Tauri owns `State` + `Option` params. #[allow(clippy::needless_pass_by_value)] #[tauri::command] #[specta::specta] -pub fn list_files_with_tags( +pub async fn list_files_with_tags( limit: u32, volume: Option, state: tauri::State<'_, AppState>, -) -> Result, String> { - let volume_id = volume - .map(|v| { - uuid::Uuid::parse_str(&v) - .map(VolumeId) - .map_err(|e| format!("bad volume UUID: {e}")) +) -> Result, CoreError> { + let volume_id = volume.as_deref().map(parse_volume_id).transpose()?; + + let out = state + .container + .tag + .execute(TagCommand::ListFilesWithTags { + filter: Some(TagFilter { + limit, + volume: volume_id, + }), }) - .transpose()?; - list_files_with_tags_inner( - state.metadata_repo.as_ref(), - state.tag_repo.as_ref(), - limit, - volume_id, - ) - .map_err(|e| e.to_string()) + .await?; + let TagOutput::FilesWithTags(files) = out else { + return Err(CoreError::Internal( + "TagCommand::ListFilesWithTags returned non-FilesWithTags output".into(), + )); + }; + + Ok(files + .into_iter() + .map(|fwt| FileWithTagsPayload { + file: FileWithMetadataPayload::from((fwt.location, fwt.metadata)), + tags: fwt.tags, + }) + .collect()) } /// Inner list-files-with-tags: two queries + merge in Rust. @@ -838,10 +1012,7 @@ where .map(|(loc, meta)| { let hash = loc.hash; let file = FileWithMetadataPayload::from((loc, meta)); - let tags = tag_map - .get(&hash) - .map(|ts| ts.iter().cloned().map(TagPayload::from).collect()) - .unwrap_or_default(); + let tags = tag_map.get(&hash).cloned().unwrap_or_default(); FileWithTagsPayload { file, tags } }) .collect()) @@ -871,22 +1042,22 @@ fn run_scan_dry( scanner: &S, hasher: &H, canonical_root: &Path, - never_cancel: &Arc, -) -> Result + never_cancel: &CancellationToken, +) -> Result where S: perima_core::Scanner + ?Sized, H: perima_core::HashService + ?Sized, { let discovered: Vec = scanner .walk(canonical_root, canonical_root)? - .take_while(|_| !never_cancel.load(Ordering::SeqCst)) + .take_while(|_| !never_cancel.is_cancelled()) .collect(); - let cancel_flag = Arc::clone(never_cancel); + let cancel_flag = never_cancel.clone(); let results: Vec> = discovered .into_par_iter() .map(|d| { - if cancel_flag.load(Ordering::SeqCst) { + if cancel_flag.is_cancelled() { return Err(perima_core::CoreError::Internal("cancelled".into())); } let h = hasher.full_hash(&d.absolute_path)?; @@ -894,19 +1065,20 @@ where }) .collect(); - let mut new_count: u64 = 0; - let mut errors: u64 = 0; + let mut files_new: u64 = 0; + let mut files_errored: u64 = 0; for r in results { match r { - Ok(_) => new_count += 1, - Err(_) => errors += 1, + Ok(_) => files_new += 1, + Err(_) => files_errored += 1, } } - Ok(ScanResult { - total: new_count + errors, - new: new_count, - existing: 0, - errors, + Ok(ScanReport { + files_seen: files_new + files_errored, + files_new, + files_updated: 0, + files_errored, + ..Default::default() }) } @@ -926,10 +1098,10 @@ async fn run_scan_live( on_persist: OnPersistFn<'_>, device_id: DeviceId, canonical_root: &Path, - never_cancel: &Arc, + never_cancel: &CancellationToken, metadata_repo: Option>, thumbnailer: Arc, -) -> Result +) -> Result where S: perima_core::Scanner + ?Sized, H: perima_core::HashService + ?Sized, @@ -971,14 +1143,14 @@ where let discovered: Vec = scanner .walk(canonical_root, canonical_root)? - .take_while(|_| !never_cancel.load(Ordering::SeqCst)) + .take_while(|_| !never_cancel.is_cancelled()) .collect(); - let cancel_flag = Arc::clone(never_cancel); + let cancel_flag = never_cancel.clone(); let results: Vec> = discovered .into_par_iter() .map(|d| { - if cancel_flag.load(Ordering::SeqCst) { + if cancel_flag.is_cancelled() { return Err(perima_core::CoreError::Internal("cancelled".into())); } let h = hasher.full_hash(&d.absolute_path)?; @@ -986,9 +1158,9 @@ where }) .collect(); - let mut new_count: u64 = 0; - let mut existing: u64 = 0; - let mut errors: u64 = 0; + let mut files_new: u64 = 0; + let mut files_updated: u64 = 0; + let mut files_errored: u64 = 0; let mut manifest_files: Vec = Vec::new(); for res in results { @@ -1019,19 +1191,19 @@ where hash: h, }); match outcome { - perima_core::UpsertOutcome::Inserted => new_count += 1, + perima_core::UpsertOutcome::Inserted => files_new += 1, perima_core::UpsertOutcome::Updated - | perima_core::UpsertOutcome::Unchanged => existing += 1, + | perima_core::UpsertOutcome::Unchanged => files_updated += 1, } } Err(e) => { tracing::warn!(error = %e, "persist failed"); - errors += 1; + files_errored += 1; } }, Err(e) => { tracing::warn!(error = %e, "hash failed, skipping"); - errors += 1; + files_errored += 1; } } } @@ -1058,11 +1230,15 @@ where } } - Ok(ScanResult { - total: new_count + existing + errors, - new: new_count, - existing, - errors, + Ok(ScanReport { + files_seen: files_new + files_updated + files_errored, + files_new, + files_updated, + files_errored, + volume_label: Some(label), + volume_mount: Some((vol_id, mount_point)), + manifest_files, + ..Default::default() }) } @@ -1105,17 +1281,40 @@ const SEARCH_LIMIT_DEFAULT: u32 = 100; /// default; callers passing anything larger than `500` get `500`. /// /// # Errors -/// Returns a string error on `SQLite`/`FTS5` errors. +/// Returns a [`CoreError`] on `SQLite`/`FTS5` errors. // WHY allow: Tauri owns `State` + primitive params. #[allow(clippy::needless_pass_by_value)] #[tauri::command] #[specta::specta] -pub fn search( +pub async fn search( query: String, limit: u32, state: tauri::State<'_, AppState>, -) -> Result, String> { - search_inner(state.search_repo.as_ref(), &query, limit).map_err(|e| e.to_string()) +) -> Result, CoreError> { + // WHY keep the empty / whitespace short-circuit in the shell: the + // `SearchUseCase` returns `CoreError::Unsupported` for an empty + // query, but the frontend's contract with pre-Batch-B `search` was + // "empty input -> []". Preserving that contract here until the + // frontend migrates to typed errors (Batch D) keeps the UI stable. + if query.trim().is_empty() { + return Ok(Vec::new()); + } + // Clamp limit: 0 -> default; anything > MAX -> MAX. + let clamped = if limit == 0 { + SEARCH_LIMIT_DEFAULT + } else { + limit.min(SEARCH_LIMIT_MAX) + }; + + let out = state + .container + .search + .execute(SearchCommand::Query { + q: query, + limit: Some(clamped), + }) + .await?; + Ok(out.hits) } /// Inner search logic extracted for testability without a live Tauri @@ -1128,7 +1327,7 @@ pub fn search_inner( repo: &dyn SearchRepository, query: &str, limit: u32, -) -> Result, CoreError> { +) -> Result, CoreError> { // Guard: empty / whitespace-only queries return `[]` without touching // FTS5. The FTS5 MATCH parser rejects empty strings. if query.trim().is_empty() { @@ -1140,18 +1339,25 @@ pub fn search_inner( } else { limit.min(SEARCH_LIMIT_MAX) }; - let hits = repo.search(query, clamped)?; - Ok(hits.into_iter().map(SearchHitPayload::from).collect()) + repo.search(query, clamped) } /// Wipe and rebuild the `FTS5` search index from the current DB state. /// /// # Errors -/// Returns a string error on `SQLite` errors. +/// Returns a [`CoreError`] on `SQLite` errors. // WHY allow: Tauri owns `State`. #[allow(clippy::needless_pass_by_value)] #[tauri::command] #[specta::specta] -pub fn search_rebuild(state: tauri::State<'_, AppState>) -> Result<(), String> { - state.search_repo.rebuild().map_err(|e| e.to_string()) +pub async fn search_rebuild(state: tauri::State<'_, AppState>) -> Result<(), CoreError> { + // WHY `SearchCommand::Rebuild` through the container: matches the + // CLI `--rebuild` pattern (`perima_app::SearchCommand::Rebuild`); + // the shell discards the empty `hits` payload. + let _: SearchOutput = state + .container + .search + .execute(SearchCommand::Rebuild) + .await?; + Ok(()) } diff --git a/crates/desktop/src/events.rs b/crates/desktop/src/events.rs index 486c007..21a2ae5 100644 --- a/crates/desktop/src/events.rs +++ b/crates/desktop/src/events.rs @@ -1,140 +1,71 @@ -//! Tauri-specific event payload types and the `TauriEventEmitter`. +//! Tauri-specific event handler for [`perima_core::AppEvent`]. //! -//! WHY separate module: `perima_core::FileEvent` uses `MediaPath` and -//! `VolumeId` which carry no framework dependencies. Adding `specta::Type` -//! or `tauri` imports to core would violate that constraint. This module -//! defines thin wrapper types that implement IPC-boundary traits without -//! touching core domain types. +//! WHY separate module: `perima_core::AppEvent` uses `MediaPath` and +//! `VolumeId` which carry no framework dependencies. Adding `tauri` imports +//! to core would violate that constraint. This module hosts only the +//! `TauriEventHandler` adapter; the `AppEvent` type itself already +//! derives `Serialize + specta::Type` (Batch E spec §4.1), so no +//! wire-mirror enum is needed here. +//! +//! WHY `FileEventPayload` was deleted (Batch D Task 8): it was a 1:1 +//! mirror of `FileEvent` with manual field-string conversions. Now that +//! `FileEvent` derives `Serialize` with `#[serde(tag = "type")]`, +//! the Tauri handler passes the full `AppEvent` envelope directly. +//! +//! WHY channel renamed from `"file-event"` to `"app-event"` (Batch E Task 11): +//! the frontend now receives the entire `AppEvent` envelope — including +//! `ScanCompleted` and `IndexInvalidated` — via a single `"app-event"` channel. +//! The previous `"file-event"` channel only delivered `FileEvent` variants. +//! `apps/desktop/src/api.ts::subscribeToAppEvents` (Task 12) is the single +//! subscriber. -use serde::Serialize; use tauri::{AppHandle, Emitter}; -use perima_core::{CoreError, EventBus, FileEvent}; +use perima_app::EventHandler; +use perima_core::AppEvent; // --------------------------------------------------------------------------- -// Wire type +// TauriEventHandler // --------------------------------------------------------------------------- -/// Payload emitted on the `"file-event"` Tauri channel. -/// -/// WHY `#[serde(tag = "type")]`: produces a discriminated union -/// `{"type":"Created","path":"...","volume":"..."}` which matches the -/// TypeScript `FileEvent` union defined in `apps/desktop/src/types.ts`. -#[derive(Debug, Clone, Serialize, specta::Type)] -#[serde(tag = "type")] -pub enum FileEventPayload { - /// A new file appeared at `path`. - Created { - /// Relative path within the volume. - path: String, - /// Volume UUID string. - volume: String, - }, - /// An existing file's content was modified. - Modified { - /// Relative path within the volume. - path: String, - /// Volume UUID string. - volume: String, - }, - /// A file was deleted from `path`. - Deleted { - /// Relative path within the volume. - path: String, - /// Volume UUID string. - volume: String, - }, - /// A file was renamed/moved within the same volume. - Renamed { - /// Previous relative path. - from: String, - /// New relative path. - to: String, - /// Volume UUID string. - volume: String, - }, -} - -impl From<&FileEvent> for FileEventPayload { - fn from(event: &FileEvent) -> Self { - match event { - FileEvent::Created { path, volume } => Self::Created { - path: path.as_str().to_owned(), - volume: volume.0.to_string(), - }, - FileEvent::Modified { path, volume } => Self::Modified { - path: path.as_str().to_owned(), - volume: volume.0.to_string(), - }, - FileEvent::Deleted { path, volume } => Self::Deleted { - path: path.as_str().to_owned(), - volume: volume.0.to_string(), - }, - FileEvent::Renamed { from, to, volume } => Self::Renamed { - from: from.as_str().to_owned(), - to: to.as_str().to_owned(), - volume: volume.0.to_string(), - }, - } - } -} - -// --------------------------------------------------------------------------- -// TauriEventEmitter -// --------------------------------------------------------------------------- - -/// Emits [`FileEventPayload`] on the `"file-event"` Tauri channel. +/// Emits [`AppEvent`] on the `"app-event"` Tauri channel. /// /// WHY `AppHandle`: `tauri::AppHandle::emit` broadcasts to all frontend /// windows without requiring a specific window reference, which is correct /// for a single-window desktop app and is forward-compatible with multi-window /// if that ever lands. -pub struct TauriEventEmitter { +/// +/// WHY `"app-event"` channel (not `"file-event"`): the full `AppEvent` +/// envelope carries `File`, `ScanCompleted`, and `IndexInvalidated` variants. +/// The frontend's `subscribeToAppEvents` in `api.ts` (Task 12) is the +/// single subscriber. The previous `"file-event"` channel is gone. +pub struct TauriEventHandler { /// The Tauri application handle used to emit events to the frontend. pub app_handle: AppHandle, } -impl std::fmt::Debug for TauriEventEmitter { +impl std::fmt::Debug for TauriEventHandler { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("TauriEventEmitter").finish_non_exhaustive() + f.debug_struct("TauriEventHandler").finish_non_exhaustive() } } -impl EventBus for TauriEventEmitter { - fn emit(&self, event: &FileEvent) -> Result<(), CoreError> { - let payload: FileEventPayload = event.into(); - self.app_handle - .emit("file-event", payload) - .map_err(|e| CoreError::Internal(format!("tauri emit: {e}"))) +#[async_trait::async_trait] +impl EventHandler for TauriEventHandler { + fn name(&self) -> &'static str { + "tauri_event_handler" } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use perima_core::{MediaPath, VolumeId}; - use uuid::Uuid; - - #[test] - fn file_event_created_serializes_correctly() { - let volume = VolumeId(Uuid::nil()); - let path = MediaPath::new("photos/img.jpg"); - let event = FileEvent::Created { - path: path.clone(), - volume, - }; - let payload: FileEventPayload = (&event).into(); - let json = serde_json::to_string(&payload).expect("serialize"); - - // Assert discriminated-union shape: {"type":"Created","path":"...","volume":"..."} - let v: serde_json::Value = serde_json::from_str(&json).expect("parse"); - assert_eq!(v["type"], "Created"); - assert_eq!(v["path"], path.as_str()); - assert_eq!(v["volume"], Uuid::nil().to_string()); + async fn handle(&mut self, event: AppEvent) { + // WHY direct emit of AppEvent: AppEvent derives Serialize + + // cfg_attr specta::Type (Batch E spec §4.1). Channel name + // "app-event" replaces the pre-Batch-E "file-event" channel — + // frontend's subscribeToAppEvents (api.ts in Task 12) is the + // single subscriber. Emitting the full envelope (not just the + // FileEvent inner) lets the frontend receive ScanCompleted + + // IndexInvalidated uniformly. + if let Err(e) = self.app_handle.emit("app-event", &event) { + tracing::warn!(error = %e, "failed to emit AppEvent to Tauri channel"); + } } } diff --git a/crates/desktop/src/lib.rs b/crates/desktop/src/lib.rs index 4ca8624..5c025a3 100644 --- a/crates/desktop/src/lib.rs +++ b/crates/desktop/src/lib.rs @@ -4,11 +4,13 @@ //! `list_volumes`, `start_watch`, `stop_watch`, `is_watching`, //! `list_tags`, `attach_tag`, `detach_tag`, `list_files_with_tags`, //! `search`, and `search_rebuild` as Tauri IPC commands. -//! `AppState` holds the resolved `Config` (data dir + device id) plus -//! shared `Arc` and `Arc` -//! handles, injected into every command via `tauri::State`. -//! `WatcherState` holds the active [`perima_fs::DebouncedWatcher`] and its -//! cancellation token. +//! +//! `AppState` holds the resolved `Config` (data dir + device id), the +//! shared `Arc` hub every migrated handler delegates to, +//! and transitional `Arc` handles retained for +//! test-only `_inner` helpers (see `state.rs` WHY-blocks). `WatcherState` +//! holds the active [`perima_fs::DebouncedWatcher`] and its cancellation +//! token. #![forbid(unsafe_code)] @@ -18,14 +20,52 @@ pub mod events; pub mod payloads; pub mod state; +use std::path::Path; use std::sync::Arc; +use perima_app::{AppContainer, AppDeps, EventHandler, LogEventHandler}; +use perima_core::{ + EventBus, FileRepository, HashService, MetadataRepository, Scanner, SearchRepository, + TagRepository, VolumeRepository, +}; use perima_db::{ - SqliteMetadataRepository, SqliteSearchRepository, SqliteTagRepository, open_and_migrate, + ReadPool, SqliteFileRepository, SqliteMetadataRepository, SqliteSearchRepository, + SqliteTagRepository, SqliteVolumeRepository, SqliteWriter, SqliteWriterHandle, }; +use perima_fs::WalkdirScanner; +use perima_hash::Blake3Service; +use perima_media::ThumbnailGenerator; use tauri::Manager; use tauri_specta::{Builder, collect_commands}; +use crate::commands::DbEventHandler; +use crate::events::TauriEventHandler; + +/// Production stub `EventBus` for writers built outside `AppContainer::new`. +/// +/// WHY a single module-level definition: `clippy::items_after_statements` +/// fires on inline `struct NoopBus` declarations inside `setup` (watch path) +/// and `build_container`. Hoisting once removes the lint and consolidates +/// the shell-local stub. The shared `perima_db::test_utils::NoopBus` is +/// gated behind the `test-utils` feature and not available to production +/// builds — see #119/#125 for long-term consolidation. +struct LocalNoopBus; +impl EventBus for LocalNoopBus { + fn emit(&self, _: &perima_core::AppEvent) -> Result<(), perima_core::CoreError> { + Ok(()) + } +} + +/// Tuple returned by [`build_container`] — collapsed via type alias to +/// satisfy `clippy::type_complexity`. +type BuildContainerOutput = ( + Arc, + SqliteWriterHandle, + Arc, + Arc, + Arc, +); + /// Boxed error type used by [`run`]. /// /// WHY: the `run()` body assembles errors from three distinct origins — @@ -43,14 +83,15 @@ pub type RunError = Box; /// /// # Errors /// Returns a [`RunError`] if config resolution fails, if TypeScript binding -/// export fails in debug builds, or if the Tauri event loop exits with an -/// error. No panic paths remain; all previously `.expect()`-ed sites now -/// propagate via `?`. +/// export fails when the `specta-export` feature is enabled, or if the Tauri +/// event loop exits with an error. No panic paths remain; all previously +/// `.expect()`-ed sites now propagate via `?`. pub fn run() -> Result<(), RunError> { // WHY: tauri-specta Builder collects #[specta::specta]-annotated commands - // and generates TypeScript bindings at build time (debug only). The invoke - // handler is then wired into tauri::Builder so the frontend can call typed - // `invoke("scan", ...)` etc. + // and generates TypeScript bindings when the `specta-export` feature is + // enabled (CI only; not release builds). The invoke handler is then wired + // into tauri::Builder so the frontend can call typed `invoke("scan", ...)` + // etc. let specta_builder = Builder::::new().commands(collect_commands![ commands::scan, commands::list_files, @@ -67,8 +108,10 @@ pub fn run() -> Result<(), RunError> { commands::search_rebuild, ]); - // Export TypeScript bindings in debug builds only. - #[cfg(debug_assertions)] + // Export TypeScript bindings when the `specta-export` feature is + // enabled. CI runs with this feature; release builds do NOT (no + // surprise filesystem writes into the frontend source tree). + #[cfg(feature = "specta-export")] specta_builder.export( specta_typescript::Typescript::default(), "../../apps/desktop/src/bindings.ts", @@ -99,29 +142,66 @@ pub fn run() -> Result<(), RunError> { .map_err(|e| format!("resolve app_data_dir: {e}"))?; let cfg = config::resolve_with_app_data_dir(&app_data_dir)?; - // WHY eager open + Arc-wrap: `SqliteMetadataRepository` holds a - // `Mutex` and is deliberately shared — commands - // clone the same `Arc` into the background `MetadataQueue` - // worker during scans. Running `open_and_migrate` here - // guarantees V001..V005 migrations run before the first - // command fires; WAL mode makes later re-opens free. + // WHY resolve db_path up-front: used for the watch writer + // (DbEventHandler) and for build_container (primary writer). let db_path = cfg.data_dir.join("perima.db"); - let metadata_conn = open_and_migrate(&db_path)?; - let metadata_repo = Arc::new(SqliteMetadataRepository::new(metadata_conn)); - - // WHY second open: `SqliteTagRepository` also holds a - // `Mutex`. Under WAL mode the second open is - // instant (no migration work — V005 already ran above). - // Separating the two connections avoids cross-locking the - // metadata and tag Mutexes on every tag command. - let tag_conn = open_and_migrate(&db_path)?; - let tag_repo = Arc::new(SqliteTagRepository::new(tag_conn)); - - // WHY third open: `SqliteSearchRepository` needs its own - // `Mutex`. Under WAL mode concurrent readers are - // never blocked by writers, so the extra handle is free. - let search_conn = open_and_migrate(&db_path)?; - let search_repo = Arc::new(SqliteSearchRepository::new(search_conn)); + + // WHY build the AppContainer here, not per-command: a single + // container is reused across every Tauri command dispatch via + // `manage(state)`. CLI builds one per dispatch (short-lived + // process); Desktop builds once because the process is + // long-running. + // + // WHY a dedicated watch writer for DbEventHandler: the + // `DbEventHandler` holds `Arc`, which + // requires a concrete (not trait-object) type. The handler + // must be constructed BEFORE `build_container` so it can be + // passed as an `EventHandler` into `AppContainer::new` — the + // single `Bus` construction site. Opening a separate + // writer+pool pair here is cheap under WAL mode; SQLite WAL + // serialises concurrent writers at the OS level. + // + // WHY the `TauriEventHandler` + `DbEventHandler` are wired + // at setup (not at `start_watch` time): the single-bus- + // construction invariant (spec §4) says exactly one + // `Bus::new` call in the codebase, inside + // `AppContainer::new`. When no watcher is active neither + // handler fires — events originate only from + // `DebouncedWatcher` which only runs while `start_watch` + // has been invoked. + let watch_writer = + SqliteWriter::start(&db_path, Arc::new(LocalNoopBus) as Arc) + .map_err(|e| format!("watch writer: {e}"))?; + let watch_reads = ReadPool::open(&db_path).map_err(|e| format!("watch pool: {e}"))?; + let watch_file_repo = Arc::new(SqliteFileRepository::new( + watch_writer.sender(), + watch_reads, + )); + let handlers: Vec> = vec![ + Box::new(LogEventHandler), + Box::new(DbEventHandler::new( + Arc::clone(&watch_file_repo), + cfg.device_id, + )), + Box::new(TauriEventHandler { + app_handle: app.handle().clone(), + }), + ]; + + let (container, writer_handle, tag_repo, metadata_repo, search_repo) = + build_container(&db_path, handlers)?; + + // WHY `manage(writer_handle)`: the writer thread stays + // alive as long as at least one `flume::Sender` + // clone exists. Every sender lives inside the repo adapters + // on the container; storing the handle lets a future + // `shutdown` command call `handle.join()` explicitly. + // WHY NOT manage `watch_writer`: the watch writer's sender + // lives inside `watch_file_repo` → `DbEventHandler` — kept + // alive by `manage(app_state)`. The handle is intentionally + // dropped here; thread reaps when all senders drop at process exit. + drop(watch_writer); + app.manage(writer_handle); let app_state = state::AppState::new( cfg.data_dir, @@ -129,6 +209,7 @@ pub fn run() -> Result<(), RunError> { metadata_repo, tag_repo, search_repo, + container, ); app.manage(app_state); @@ -137,3 +218,84 @@ pub fn run() -> Result<(), RunError> { .run(tauri::generate_context!())?; Ok(()) } + +/// Build the [`AppContainer`] that backs every migrated Tauri handler. +/// +/// WHY a dedicated helper (mirrors `crates/cli/src/main.rs::build_container`): +/// keeps the shared-handle wiring and extra-handler plumbing in one place so +/// the Tauri `.setup` closure stays focused on control-flow. +/// +/// WHY `tag_repo` + `metadata_repo` + `search_repo` are returned: `AppState` +/// retains concrete-typed `Arc`s for the `_inner` test-helper seam — those +/// helpers construct their own repos per-call and need the concrete type for +/// methods not exposed by the trait object. +fn build_container( + db_path: &Path, + handlers: Vec>, +) -> Result { + // WHY a `NoopBus` to the writer: the writer's after-COMMIT emission + // path is scaffolded but `AppEvent` emission is handled by + // `AppContainer`'s `Bus` (Batch E). Batch E's `async-broadcast` + // will re-plumb this once the single-construction-site invariant is + // relaxed. Spec §§3.3 + 4.8 (A4.8 first bullet). + let writer_bus: Arc = Arc::new(LocalNoopBus); + let writer = SqliteWriter::start(db_path, writer_bus)?; + let reads = ReadPool::open(db_path)?; + + // WHY clone `reads` for each adapter: `ReadPool` is cheap to + // [`Clone`] (inner `r2d2::Pool` is `Arc`-backed). + let files: Arc = + Arc::new(SqliteFileRepository::new(writer.sender(), reads.clone())); + let volumes: Arc = + Arc::new(SqliteVolumeRepository::new(writer.sender(), reads.clone())); + let tag_repo = Arc::new(SqliteTagRepository::new(writer.sender(), reads.clone())); + let metadata_repo = Arc::new(SqliteMetadataRepository::new( + writer.sender(), + reads.clone(), + )); + let search_repo = Arc::new(SqliteSearchRepository::new(writer.sender(), reads)); + // WHY `.clone()` not `Arc::clone(&_)`: `AppDeps::{tags,metadata,search}` + // are `Arc`. Method-syntax `.clone()` anchors `T = SqliteX` from + // the receiver, returns `Arc`, and the let binding triggers + // the `Arc -> Arc` unsize coercion at assignment. + // The UFCS form `Arc::clone(&tag_repo)` fails: bidirectional inference + // picks `T = dyn _` from the let-binding type, then complains the input + // `&Arc` doesn't match `&Arc` (coercion does not apply + // through references). CI macOS surfaced this on PR #130. + let tags: Arc = tag_repo.clone(); + let metadata: Arc = metadata_repo.clone(); + let search: Arc = search_repo.clone(); + let hasher: Arc = Arc::new(Blake3Service::new()); + let scanner: Arc = Arc::new(WalkdirScanner::new()); + + // WHY thumbnailer rooted at `data_dir` (db parent): the Tauri + // asset-protocol scope (`tauri.conf.json`) exposes + // `$APPDATA/perima/thumbnails/**`; resolving the generator to the + // same directory tree keeps `convertFileSrc` calls from the + // frontend working end-to-end. + let thumbnailer = Arc::new(ThumbnailGenerator::new( + db_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(), + )); + + let deps = AppDeps { + files, + volumes, + tags, + metadata, + search, + hasher, + scanner, + thumbnailer, + }; + + Ok(( + AppContainer::new(deps, handlers), + writer, + tag_repo, + metadata_repo, + search_repo, + )) +} diff --git a/crates/desktop/src/payloads.rs b/crates/desktop/src/payloads.rs index ca4e031..5c004e7 100644 --- a/crates/desktop/src/payloads.rs +++ b/crates/desktop/src/payloads.rs @@ -1,13 +1,24 @@ //! Wire-types for commands that join multiple domain records. //! -//! WHY separate module: `commands.rs` already hosts `FileEntry` and -//! `VolumeEntry`, which map 1:1 to a single `perima-core` record. -//! Payloads that flatten a `(record, optional_record)` pair — like -//! `FileWithMetadataPayload` — carry their own `From` impls and grow -//! fast as v0.4.x lands thumbnails and derived attributes. Keeping them -//! in their own module prevents `commands.rs` from sprawling. +//! WHY separate module: `crates/desktop/src/commands.rs` previously hosted +//! `FileEntry` and `VolumeEntry`, which mapped 1:1 to single `perima-core` +//! records. Those were deleted in Batch D Task 8 — the core types now derive +//! `specta::Type` and are used directly in handler signatures. This module +//! retains only the composite payloads that flatten a `(record, +//! optional_record)` pair with no clean 1:1 core analogue. +//! +//! WHY `TagPayload` and `SearchHitPayload` were deleted (Batch D Task 8): +//! `perima_core::Tag` and `perima_core::SearchHit` now derive +//! `specta::Type`; no shell-side mirror is needed. Handlers return the +//! core types directly. +//! +//! WHY `FileWithMetadataPayload` is retained (spec §8 #6): it flattens a +//! `(FileLocationRecord, Option)` pair into a single-level +//! object that the UI grid can bind without traversing optional sub-objects. +//! There is no equivalent flat type in `perima-core`. The same rationale +//! applies to `FileWithTagsPayload`. -use perima_core::{FileLocationRecord, MediaMetadata, SearchHit, Tag}; +use perima_core::{FileLocationRecord, MediaMetadata, Tag}; use serde::Serialize; /// Flattened `(FileLocationRecord, Option)` pair for the @@ -17,11 +28,10 @@ use serde::Serialize; /// binds one row per location and needs every column addressable with a /// single key. Nesting would force every cell to traverse an optional /// subobject just to discover it is absent. Flat fields with `None` -/// columns match SQL's native shape and the existing `FileEntry` -/// encoding. +/// columns match SQL's native shape and the existing encoding. #[derive(Debug, Clone, Serialize, specta::Type)] pub struct FileWithMetadataPayload { - // File-location fields (mirror `FileEntry`). + // File-location fields (mirrors `FileLocationRecord`). /// BLAKE3-256 content hash as lowercase hex. pub hash: String, /// File size in bytes. @@ -63,40 +73,23 @@ pub struct FileWithMetadataPayload { pub thumbnail_status: Option, } -/// Wire-type for a tag, safe to cross the IPC boundary. -#[derive(Debug, Clone, Serialize, specta::Type)] -pub struct TagPayload { - /// `UUIDv7` primary key. - pub id: String, - /// NFC-normalized lowercase name. - pub name: String, - /// ISO 8601 UTC timestamp of first sighting. - pub first_seen: String, -} - -impl From for TagPayload { - fn from(t: Tag) -> Self { - Self { - id: t.id.to_string(), - name: t.name, - first_seen: t.first_seen, - } - } -} - /// File-with-metadata plus its attached tags. /// /// WHY compose (not extend `FileWithMetadataPayload`): keeps each -/// payload focused. The `tags` field is a Vec, not a flat field set, +/// payload focused. The `tags` field is a `Vec`, not a flat field set, /// so it doesn't fit the "one-column-per-SQL-field" flat pattern of /// the metadata payload. +/// +/// WHY `tags: Vec` (not `Vec`): `perima_core::Tag` +/// now derives `specta::Type`; no shell-side mirror is needed +/// (Batch D Task 8). #[derive(Debug, Clone, Serialize, specta::Type)] pub struct FileWithTagsPayload { /// All file + metadata fields (flat). #[serde(flatten)] pub file: FileWithMetadataPayload, /// Tags attached to this content hash. - pub tags: Vec, + pub tags: Vec, } impl From<(FileLocationRecord, Option)> for FileWithMetadataPayload { @@ -158,31 +151,3 @@ impl From<(FileLocationRecord, Option)> for FileWithMetadataPaylo } } } - -/// Wire-type for a single `FTS5` search result. -/// -/// Maps 1:1 from [`SearchHit`]; exists as a separate type so `specta` -/// can generate TypeScript bindings without pulling the domain type into -/// the desktop crate's public surface. -#[derive(Debug, Clone, Serialize, specta::Type)] -pub struct SearchHitPayload { - /// `BLAKE3` hex hash of the file content. - pub blake3_hash: String, - /// Volume UUID string. - pub volume_id: String, - /// Relative path within the volume (representative location). - pub relative_path: String, - /// `BM25` rank (lower = better, `SQLite` convention). - pub rank: f64, -} - -impl From for SearchHitPayload { - fn from(h: SearchHit) -> Self { - Self { - blake3_hash: h.blake3_hash, - volume_id: h.volume_id, - relative_path: h.relative_path, - rank: h.rank, - } - } -} diff --git a/crates/desktop/src/state.rs b/crates/desktop/src/state.rs index 5e0fdb4..2653c46 100644 --- a/crates/desktop/src/state.rs +++ b/crates/desktop/src/state.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use std::sync::Arc; +use perima_app::AppContainer; use perima_core::DeviceId; use perima_db::{SqliteMetadataRepository, SqliteSearchRepository, SqliteTagRepository}; use tokio::sync::Mutex; @@ -11,10 +12,11 @@ use tokio_util::sync::CancellationToken; /// State shared across all Tauri commands via `tauri::State`. /// /// WHY `data_dir` + `device_id` without a general DB handle: commands -/// that read through `FileRepository` / `VolumeRepository` open their -/// own `rusqlite::Connection` per-call. That adapter predates `&self` -/// traits and uses `&mut self`, which collides with `Arc`-sharing. WAL -/// mode makes the second open a no-op so per-call-open is cheap. +/// that read through `FileRepository` still open their own +/// `rusqlite::Connection` per-call (Tasks 3-6 migrate them to the +/// writer actor). `VolumeRepository` as of Batch C Task 2 goes through +/// `container.volumes` — no per-call open. WAL mode makes the remaining +/// second open cheap for tests and the not-yet-migrated repos. /// /// WHY `metadata_repo: Arc` is a deliberate /// deviation from the per-call-open pattern: the `MetadataRepository` @@ -26,26 +28,45 @@ use tokio_util::sync::CancellationToken; /// the v0.4.0 plan calls this out explicitly. pub struct AppState { /// Resolved data directory (where `perima.db` lives). + /// + /// WHY kept post-Batch-B: `start_watch` + the `_inner` test helpers + /// (`run_scan_inner`, `list_files_inner`, `list_volumes_inner`) still + /// open short-lived per-call connections rooted at this directory. + /// Remove once those sites are fully ported (tracked as post-Batch-B + /// cleanup; see spec §5 risk mitigation "additive `AppState`"). pub data_dir: PathBuf, /// Stable device identifier. pub device_id: DeviceId, /// Shared metadata repository handle. /// - /// See struct-level WHY for the rationale behind holding this - /// directly (rather than re-opening a connection per command). + /// WHY retained during Batch B: test helpers in + /// `crates/desktop/tests/commands_test.rs` still construct this repo + /// directly via `_inner` entry points. The migrated `#[tauri::command]` + /// handlers go through `container.metadata.execute` instead; this + /// field's only remaining consumer is the preserved test-helper seam. pub metadata_repo: Arc, /// Shared tag repository handle. /// - /// WHY `Arc` (same pattern as `metadata_repo`): - /// `TagRepository` trait uses `&self` with interior `Mutex`, - /// so a single handle can be shared across commands without per-call-open. + /// WHY retained: same rationale as `metadata_repo` — preserved during + /// Batch B for the `_inner` test-helper contract; migrated commands + /// call `container.tag.execute` instead. pub tag_repo: Arc, /// Shared search repository handle. /// - /// WHY `Arc`: `SearchRepository` uses `&self` - /// with interior `Mutex`, enabling Arc-sharing across commands - /// without per-call connection opens. + /// WHY retained: same rationale as `metadata_repo` — preserved during + /// Batch B for the `_inner` test-helper contract; migrated commands + /// call `container.search.execute` instead. pub search_repo: Arc, + /// Single orchestration hub — every migrated handler calls through + /// one of the `UseCase` fields on this container. + /// + /// WHY `Arc`: `AppContainer` is already internally + /// `Arc`-shared across its `UseCase` fields (see `perima_app::container`). + /// Wrapping the container itself in an `Arc` lets Tauri's + /// `manage(state)` move the value without forcing a clone per command + /// dispatch; every command then accesses `state.container.*` through + /// a single dereference. + pub container: Arc, } impl std::fmt::Debug for AppState { @@ -55,12 +76,14 @@ impl std::fmt::Debug for AppState { } impl AppState { - /// Construct a new `AppState` from a resolved config, metadata repo, and - /// tag repo. + /// Construct a new `AppState` from a resolved config + repo handles + + /// an assembled [`AppContainer`]. /// /// WHY a constructor (rather than public struct literal): keeps the - /// Arc-sharing contract for both repos explicit at the single - /// construction site in `run()`. + /// Arc-sharing contract for every field explicit at the single + /// construction site in `run()`. Also preserves the additive-migration + /// invariant — callers that forget to pass `container` get a compile + /// error rather than a silently missing dependency. #[must_use] pub const fn new( data_dir: PathBuf, @@ -68,6 +91,7 @@ impl AppState { metadata_repo: Arc, tag_repo: Arc, search_repo: Arc, + container: Arc, ) -> Self { Self { data_dir, @@ -75,6 +99,7 @@ impl AppState { metadata_repo, tag_repo, search_repo, + container, } } } diff --git a/crates/desktop/tests/bindings_compile.rs b/crates/desktop/tests/bindings_compile.rs new file mode 100644 index 0000000..d6c1d66 --- /dev/null +++ b/crates/desktop/tests/bindings_compile.rs @@ -0,0 +1,108 @@ +//! Sanity check that the tauri-specta builder produces a parseable +//! TypeScript export containing the expected IPC type graph. +//! +//! WHY a separate integration test (not relying on `lib.rs::run`'s +//! `#[cfg(feature = "specta-export")]` path): this isolates the +//! "derives compile cleanly + exporter doesn't choke on our type +//! graph" assertion from the full Tauri runtime initialization. +//! Catches `specta::Type` derive failures separately from `cargo build` +//! and surfaces a meaningful diagnostic when a new core type was added +//! but its `specta` derive forgot the feature gate or some required +//! attribute. +//! +//! WHY `tempfile` (not `export_str`): `tauri-specta =2.0.0-rc.24` only +//! exposes `Builder::export(language, path)` — the `export_str` method +//! visible in rc.21 docs was removed before rc.24. We write to a +//! `NamedTempFile` and read back to string; the file is deleted when +//! the `NamedTempFile` handle drops at end-of-test. + +use specta_typescript::Typescript; +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 +/// caught loudly at compile time in exactly one place. +fn build_test_builder() -> Builder { + Builder::::new().commands(collect_commands![ + commands::scan, + commands::list_files, + commands::list_files_with_metadata, + commands::list_volumes, + commands::start_watch, + commands::stop_watch, + commands::is_watching, + commands::list_tags, + commands::attach_tag, + commands::detach_tag, + commands::list_files_with_tags, + commands::search, + commands::search_rebuild, + ]) +} + +/// 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. +/// +/// 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). +/// +/// WHY `AppEvent` / `FileEvent` / `InvalidationReason` are NOT asserted +/// here: those types cross the IPC boundary via the `"app-event"` Tauri +/// channel (Batch E Task 11), not as command arguments or returns. +/// `tauri-specta` only walks `commands()` registrations, so channel-only +/// types are not emitted by `Builder::export`. Per Batch E E-12, those +/// type declarations are hand-crafted in `apps/desktop/src/bindings.ts` +/// for v1; the `bindings-drift` CI job (Batch D D-12) compares the +/// committed file against regeneration. A future tauri-specta `events!` +/// registration would let those types flow through this same generator +/// (Batch H or later); until then this test deliberately scopes itself +/// to command-graph types. +#[test] +fn tauri_specta_builder_exports_full_ipc_type_graph() { + let tmp = tempfile::NamedTempFile::new().expect("create tempfile for bindings export"); + build_test_builder() + .export(Typescript::default(), tmp.path()) + .expect("specta TypeScript export to tempfile must not fail"); + + let ts = std::fs::read_to_string(tmp.path()).expect("read generated TypeScript from tempfile"); + + assert!( + !ts.is_empty(), + "generated TypeScript bindings must be non-empty" + ); + + // Core types on the wire as command args / returns + transitive + // fields. See doc comment for why event-channel types are excluded. + for ty in [ + "CoreError", + "ScanReport", + "FileLocationRecord", + "VolumeRecord", + "SearchHit", + "BlakeHash", + ] { + assert!(ts.contains(ty), "{ty} missing from bindings"); + } + + // WHY tightened "Tag" check: bare substring matches `TagOutput`, + // `Tagged`, `FileWithTagsPayload` etc. — pin to a top-level TS + // declaration boundary so a missing core `Tag` re-export is caught. + assert!( + ts.contains("type Tag ") || ts.contains("type Tag\n") || ts.contains("interface Tag "), + "Tag missing from bindings (top-level declaration)" + ); + + // Composite payloads retained (deliberate flat composites with no + // clean 1:1 core analogue, per spec §8 #6). + for ty in ["FileWithMetadataPayload", "FileWithTagsPayload"] { + assert!(ts.contains(ty), "{ty} missing from bindings"); + } +} diff --git a/crates/desktop/tests/commands_test.rs b/crates/desktop/tests/commands_test.rs index 95451d5..8900aa7 100644 --- a/crates/desktop/tests/commands_test.rs +++ b/crates/desktop/tests/commands_test.rs @@ -5,13 +5,22 @@ //! which accept plain `Path` + `DeviceId` arguments. The underlying logic is //! identical — only the Tauri IPC wrapping is absent. +// WHY file-level allow: each test fn defines an inline `struct NoopBus` +// stub. Hoisting one helper at module scope is the long-term consolidation +// target (#119/#125), but for v1 these test fixtures intentionally stay +// self-contained — moving them now would expand the diff beyond the +// "make CI green" scope. +#![allow(clippy::items_after_statements)] + use std::io::Write; use std::path::Path; use std::sync::Arc; -use perima_core::{BlakeHash, DeviceId, MediaMetadata, MetadataRepository, SearchRepository}; +use perima_core::{ + AppEvent, CoreError, DeviceId, EventBus, MediaMetadata, MetadataRepository, SearchRepository, +}; use perima_db::{ - SqliteMetadataRepository, SqliteSearchRepository, SqliteTagRepository, open_and_migrate, + ReadPool, SqliteMetadataRepository, SqliteSearchRepository, SqliteTagRepository, SqliteWriter, }; use perima_desktop::commands::{ attach_tag_inner, detach_tag_inner, list_files_inner, list_files_with_metadata_inner, @@ -62,7 +71,7 @@ fn write_tiny_png(path: &Path, fill: [u8; 3]) { .expect("write png"); } -/// Scan three fixture files and assert `total=3, new=3, errors=0`. +/// Scan three fixture files and assert `files_seen=3, files_new=3, files_errored=0`. #[tokio::test] async fn scan_indexes_files() { let fixture_dir = tempfile::tempdir().expect("tempdir for fixtures"); @@ -79,13 +88,25 @@ async fn scan_indexes_files() { .await .expect("scan_inner should succeed"); + // WHY ScanReport fields (not ScanResult): Batch D Task 8 deleted the + // shell-side ScanResult mirror; run_scan_inner now returns ScanReport + // from crates/app directly. files_seen == files_new + files_updated + + // files_errored for a clean first-run scan. assert_eq!( - result.total, 3, + result.files_seen, 3, "expected 3 total files, got {}", - result.total + result.files_seen + ); + assert_eq!( + result.files_new, 3, + "expected 3 new files, got {}", + result.files_new + ); + assert_eq!( + result.files_errored, 0, + "expected 0 errors, got {}", + result.files_errored ); - assert_eq!(result.new, 3, "expected 3 new files, got {}", result.new); - assert_eq!(result.errors, 0, "expected 0 errors, got {}", result.errors); } /// After a successful scan, `list_files_inner` must return all 3 records. @@ -128,12 +149,31 @@ async fn list_files_with_metadata_returns_rows() { // Attach a metadata row to one of the scanned files. We pull its // hash from `list_files_inner` to guarantee FK-compatibility with // the `files` row the scanner just inserted. + // + // WHY `entries[0].hash` is now `BlakeHash` (not `String`): Batch D Task 8 + // deleted the `FileEntry` wire mirror; `list_files_inner` now returns + // `Vec` where `hash` is a typed `BlakeHash` value. let entries = list_files_inner(data_dir.path(), 100, None).expect("list_files_inner"); assert!(!entries.is_empty(), "scan must have inserted ≥1 file"); - let first_hash = BlakeHash::parse_hex(&entries[0].hash).expect("parse hash"); + let first_hash = entries[0].hash; let db_path = data_dir.path().join("perima.db"); - let repo = SqliteMetadataRepository::new(open_and_migrate(&db_path).expect("open")); + // WHY writer+pool harness (post-Batch-C Task 4): the metadata + // adapter now takes `(flume::Sender, ReadPool)`. + // `run_scan_inner` above opened its own writer + dropped it at + // scope end; we spin up a fresh writer here and keep its handle + // alive via `_writer` until teardown (WAL lets the two writers + // coexist). + struct TestNoopBus; + impl EventBus for TestNoopBus { + fn emit(&self, _: &AppEvent) -> Result<(), CoreError> { + Ok(()) + } + } + let bus: Arc = Arc::new(TestNoopBus); + let writer = SqliteWriter::start(&db_path, bus).expect("writer start"); + let reads = ReadPool::open(&db_path).expect("pool open"); + let repo = SqliteMetadataRepository::new(writer.sender(), reads); let meta = MediaMetadata { hash: first_hash, width: Some(640), @@ -158,14 +198,22 @@ async fn list_files_with_metadata_returns_rows() { !rows.is_empty(), "expected ≥1 FileWithMetadataPayload row, got 0" ); + // WHY `entries[0].hash.to_hex()`: `FileWithMetadataPayload.hash` is a + // hex String (flat IPC payload); `FileLocationRecord.hash` is `BlakeHash`. + // Compare using the hex representation. let populated = rows .iter() - .find(|r| r.hash == entries[0].hash) + .find(|r| r.hash == entries[0].hash.to_hex()) .expect("row for inserted metadata must be present"); assert_eq!(populated.width, Some(640)); assert_eq!(populated.height, Some(480)); assert_eq!(populated.camera_make.as_deref(), Some("Acme")); assert_eq!(populated.mime_type.as_deref(), Some("image/jpeg")); + + // Tear down explicitly — drops the repo's sender clone + reaps + // the writer thread cleanly before the tempdir is removed. + drop(repo); + writer.join(); } /// After a successful scan, `list_volumes_inner` must return at least one volume. @@ -206,8 +254,22 @@ async fn desktop_scan_populates_metadata_and_thumbnails() { // in `crates/desktop/src/lib.rs::run`): a single // `SqliteMetadataRepository` Arc cloned into the queue worker and // inspected directly after the scan drain completes. - let metadata_conn = open_and_migrate(&db_path).expect("open metadata"); - let metadata_repo = Arc::new(SqliteMetadataRepository::new(metadata_conn)); + // + // WHY writer+pool harness (post-Batch-C Task 4): the adapter now + // takes `(flume::Sender, ReadPool)`. `run_scan_inner_with_metadata` + // below opens its own writer internally; we keep a separate + // writer for this test's direct assertions (WAL lets both + // coexist). + struct MetaTestNoopBus; + impl EventBus for MetaTestNoopBus { + fn emit(&self, _: &AppEvent) -> Result<(), CoreError> { + Ok(()) + } + } + let bus: Arc = Arc::new(MetaTestNoopBus); + let writer = SqliteWriter::start(&db_path, bus).expect("writer start"); + let reads = ReadPool::open(&db_path).expect("pool open"); + let metadata_repo = Arc::new(SqliteMetadataRepository::new(writer.sender(), reads)); let result = run_scan_inner_with_metadata( fixture_dir.path(), @@ -219,8 +281,17 @@ async fn desktop_scan_populates_metadata_and_thumbnails() { .await .expect("scan with metadata should succeed"); - assert_eq!(result.total, 2, "expected 2 files, got {}", result.total); - assert_eq!(result.new, 2, "expected 2 new, got {}", result.new); + // WHY ScanReport fields: see scan_indexes_files test comment. + assert_eq!( + result.files_seen, 2, + "expected 2 files, got {}", + result.files_seen + ); + assert_eq!( + result.files_new, 2, + "expected 2 new, got {}", + result.files_new + ); // Assert 2 metadata rows exist via the shared handle. The drain // path above guarantees the worker has persisted by the time we @@ -260,6 +331,11 @@ async fn desktop_scan_populates_metadata_and_thumbnails() { thumb_root.display(), thumb_count, ); + + // Tear down explicitly — drops the repo's sender clone + reaps + // the writer thread cleanly before the tempdir is removed. + drop(metadata_repo); + writer.join(); } /// Exercises the four tag `_inner` helpers end-to-end: @@ -277,24 +353,43 @@ async fn list_files_with_tags_returns_tagged_rows() { .await .expect("scan"); - // Open a tag repo against the same DB. + // Open tag + metadata repos against the same DB. Post-Batch-C + // Tasks 3 + 4, both `SqliteTagRepository` and + // `SqliteMetadataRepository` require `(writer.sender(), ReadPool)`; + // spin up a dedicated writer for this test and keep its handle + // alive via `writer` until teardown. WAL mode lets this writer + // coexist with the one spawned internally by `run_scan_inner` + // above. let db_path = data_dir.join("perima.db"); - let tag_conn = open_and_migrate(&db_path).expect("open tag conn"); - let tag_repo = SqliteTagRepository::new(tag_conn); - // Get files list to find a hash. - let metadata_conn = open_and_migrate(&db_path).expect("open meta conn"); - let metadata_repo = SqliteMetadataRepository::new(metadata_conn); + struct TestNoopBus; + impl EventBus for TestNoopBus { + fn emit(&self, _: &AppEvent) -> Result<(), CoreError> { + Ok(()) + } + } + let bus: Arc = Arc::new(TestNoopBus); + let writer = SqliteWriter::start(&db_path, bus).expect("writer start"); + let reads = ReadPool::open(&db_path).expect("pool open"); + let tag_repo = SqliteTagRepository::new(writer.sender(), reads.clone()); + let metadata_repo = SqliteMetadataRepository::new(writer.sender(), reads); + + // Get files list to find a hash. `FileWithMetadataPayload.hash` is a + // hex String (flat composite payload retained in Batch D Task 8). let files = list_files_with_metadata_inner(&metadata_repo, 100, None).expect("list"); assert!(!files.is_empty(), "scan must have produced ≥1 file"); let first_hash = files[0].hash.clone(); // Attach a tag via the inner helper. + // WHY `attach_tag_inner` now returns `Tag` (not `TagPayload`): + // Batch D Task 8 deleted TagPayload; Tag is the core type. let tag = attach_tag_inner(&tag_repo, &first_hash, "test-tag", device).expect("attach"); assert_eq!(tag.name, "test-tag"); // List files with tags — the tagged file must appear with 1 tag. + // WHY `fwt.tags` is `Vec` now: FileWithTagsPayload.tags was + // updated from Vec to Vec in Batch D Task 8. let tagged = list_files_with_tags_inner(&metadata_repo, &tag_repo, 100, None).expect("list with tags"); assert!(!tagged.is_empty()); @@ -305,12 +400,14 @@ async fn list_files_with_tags_returns_tagged_rows() { assert_eq!(tagged_file.tags.len(), 1, "must have exactly 1 tag"); assert_eq!(tagged_file.tags[0].name, "test-tag"); - // List tags — must return exactly 1. + // List tags — must return exactly 1. `list_tags_inner` now returns + // `Vec` directly. let tags = list_tags_inner(&tag_repo).expect("list tags"); assert_eq!(tags.len(), 1); - // Detach. - detach_tag_inner(&tag_repo, &first_hash, &tag.id, device).expect("detach"); + // Detach. WHY `tag.id.to_string()`: `Tag.id` is `Uuid` (not String); + // `detach_tag_inner` takes `tag_id_str: &str` and parses it internally. + detach_tag_inner(&tag_repo, &first_hash, &tag.id.to_string(), device).expect("detach"); // Verify empty after detach. let tagged2 = list_files_with_tags_inner(&metadata_repo, &tag_repo, 100, None) @@ -320,6 +417,12 @@ async fn list_files_with_tags_returns_tagged_rows() { .find(|f| f.file.hash == first_hash) .expect("find file after detach"); assert!(tagged_file2.tags.is_empty(), "no tags after detach"); + + // Tear down explicitly — drops both repos' sender clones + reaps + // the writer thread cleanly before the tempdir is removed. + drop(tag_repo); + drop(metadata_repo); + writer.join(); } /// Regression for v0.4.3: thumbnail directory referenced by @@ -387,10 +490,27 @@ async fn search_returns_hit_after_scan_and_rebuild() { .expect("scan"); let db_path = data_dir.join("perima.db"); - let search_conn = open_and_migrate(&db_path).expect("open search conn"); - let search_repo = SqliteSearchRepository::new(search_conn); + // WHY spawn a test-local writer + pool: the repo adapter is constructed + // from `(Sender, ReadPool)`, and this integration test drives + // search without going through `AppContainer`. The repo clones the + // sender; dropping `search_writer` at end-of-test closes the channel + // and lets the writer thread exit. + struct NoopBus; + impl EventBus for NoopBus { + fn emit(&self, _: &AppEvent) -> Result<(), CoreError> { + Ok(()) + } + } + let search_writer = SqliteWriter::start(&db_path, Arc::new(NoopBus) as Arc) + .expect("search writer"); + let search_reads = ReadPool::open(&db_path).expect("search pool"); + let search_repo = SqliteSearchRepository::new(search_writer.sender(), search_reads); + drop(search_writer); search_repo.rebuild().expect("rebuild index"); + // WHY `h.relative_path` on `SearchHit`: Batch D Task 8 deleted + // `SearchHitPayload`; `search_inner` now returns `Vec` + // from `perima_core`. `SearchHit.relative_path` is a `String`. // `alpha.txt` is one of the mk_fixture files; unicode61 splits on // `.` so the `alpha` token is indexed. let hits = search_inner(&search_repo, "alpha", 10).expect("search"); diff --git a/crates/fs/src/errors.rs b/crates/fs/src/errors.rs index c9d0d99..b0d408b 100644 --- a/crates/fs/src/errors.rs +++ b/crates/fs/src/errors.rs @@ -19,7 +19,9 @@ pub enum Error { impl From for perima_core::CoreError { fn from(e: Error) -> Self { match e { - Error::Io(io) => Self::Io(io), + // WHY: delegate through CoreError's From impl so the + // kind+message lowering stays in one place (errors.rs in core). + Error::Io(io) => Self::from(io), Error::NotUnderVolume(p) => Self::InvalidPath(p.display().to_string()), } } diff --git a/crates/fs/src/watcher.rs b/crates/fs/src/watcher.rs index 810ca07..0d3daee 100644 --- a/crates/fs/src/watcher.rs +++ b/crates/fs/src/watcher.rs @@ -11,7 +11,7 @@ use std::time::Duration; use notify::RecursiveMode; use notify::event::{EventKind, ModifyKind, RenameMode}; use notify_debouncer_full::new_debouncer; -use perima_core::{CoreError, EventBus, FileEvent, VolumeId}; +use perima_core::{AppEvent, CoreError, EventBus, FileEvent, VolumeId}; use tokio_util::sync::CancellationToken; use crate::paths::relativize; @@ -66,11 +66,15 @@ impl DebouncedWatcher { // volume_root ensures they match whatever the OS reports in events. // crate::platform_path::canonicalize wraps dunce on Windows (avoids // UNC prefix pollution) and std::fs::canonicalize elsewhere. + // WHY closure not CoreError::Io: Io is now a struct variant (not a + // tuple variant), so it cannot be used as a function pointer. + // Delegating through CoreError::from(io) keeps the kind+message + // lowering in one place. let canonical_root = - crate::platform_path::canonicalize(volume_root).map_err(CoreError::Io)?; + crate::platform_path::canonicalize(volume_root).map_err(CoreError::from)?; let canonical_paths: Vec = paths .iter() - .map(|p| crate::platform_path::canonicalize(p).map_err(CoreError::Io)) + .map(|p| crate::platform_path::canonicalize(p).map_err(CoreError::from)) .collect::>()?; // WHY std mpsc: notify-debouncer-full uses std::sync::mpsc for its @@ -139,8 +143,8 @@ fn run_event_loop( if cancel.is_cancelled() { return; } - if let Some(event) = map_event(&de.event, volume_root, volume_id) - && let Err(e) = bus.emit(&event) + if let Some(file_event) = map_event(&de.event, volume_root, volume_id) + && let Err(e) = bus.emit(&AppEvent::File(file_event)) { tracing::warn!(error = %e, "EventBus::emit failed"); } @@ -207,7 +211,7 @@ mod tests { use std::sync::Mutex; use std::time::Duration; - use perima_core::{CoreError, EventBus, FileEvent, VolumeId}; + use perima_core::{AppEvent, CoreError, EventBus, FileEvent, VolumeId}; use tempfile::TempDir; use tokio_util::sync::CancellationToken; @@ -219,11 +223,13 @@ mod tests { } impl EventBus for MockEventBus { - fn emit(&self, event: &FileEvent) -> Result<(), CoreError> { - self.events - .lock() - .expect("MockEventBus mutex poisoned") - .push(event.clone()); + fn emit(&self, event: &AppEvent) -> Result<(), CoreError> { + if let AppEvent::File(file_event) = event { + self.events + .lock() + .expect("MockEventBus mutex poisoned") + .push(file_event.clone()); + } Ok(()) } } diff --git a/crates/hash/src/errors.rs b/crates/hash/src/errors.rs index f35b262..c6aebbf 100644 --- a/crates/hash/src/errors.rs +++ b/crates/hash/src/errors.rs @@ -13,7 +13,9 @@ pub enum Error { impl From for perima_core::CoreError { fn from(e: Error) -> Self { match e { - Error::Io(io) => Self::Io(io), + // WHY: delegate through CoreError's From impl so the + // kind+message lowering stays in one place (errors.rs in core). + Error::Io(io) => Self::from(io), } } } diff --git a/deny.toml b/deny.toml index 0273d2d..2de8653 100644 --- a/deny.toml +++ b/deny.toml @@ -78,3 +78,8 @@ ignore = [ unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] +# Temporary git source for refinery PR #425 (rusqlite 0.39 support). +# Removed alongside the [patch.crates-io] entry in Cargo.toml once +# the upstream PR merges + ships a refinery release that supports +# rusqlite 0.39 on crates.io. See GH issue tracking the workaround. +allow-git = ["https://github.com/tomasol/refinery"] diff --git a/justfile b/justfile index 3b8b7cb..883dc05 100644 --- a/justfile +++ b/justfile @@ -14,8 +14,20 @@ export LIBRARY_PATH := "/tmp/tauri-libs:/usr/lib/x86_64-linux-gnu" default: ci +# WHY nextest not `cargo test`: SQLite lock-order inversion in +# unixClose vs unixLock-from-WAL-close deadlocks when multiple Connection +# handles to the same DB drop concurrently (caught locally + on CI several +# times during arch-audit). cargo nextest's process-per-test isolation +# eliminates the race. The `no-cargo-test` recipe below enforces the rule +# repo-wide so accidental reintroduction fails CI. test: - cargo test --workspace --all-targets + cargo nextest run --workspace --all-targets + +# Forbid `cargo test` invocations in committed automation files. +# Doctest forms (cargo test --doc / --workspace --doc) are exempt because +# nextest doesn't run doctests. See scripts/no-cargo-test.sh + CLAUDE.md. +no-cargo-test: + ./scripts/no-cargo-test.sh clippy: cargo clippy --workspace --all-targets -- -D warnings @@ -44,6 +56,14 @@ test-frontend: lint-frontend: cd apps/desktop && bun install --frozen-lockfile && bun run lint +# Regenerate apps/desktop/src/bindings.ts via tauri-specta and verify +# it doesn't drift from the committed copy. Mirrors the CI bindings-drift +# job. Run pre-push when you've touched a #[tauri::command] signature, a +# specta-derived type, or a CoreError variant. +bindings: + cargo build -p perima-desktop --features specta-export + git diff --exit-code apps/desktop/src/bindings.ts + deny: cargo deny check @@ -55,7 +75,7 @@ ci-fast: fmt-check typos lint-frontend # Thick gate — pre-push + manual surface. Equivalent to old `ci`; # kept for back-compat so `just ci` still runs the full pipeline. -ci: fmt-check clippy test doctest docs-coverage deny typos build-frontend test-frontend lint-frontend +ci: fmt-check clippy test no-cargo-test doctest docs-coverage deny typos build-frontend test-frontend lint-frontend verify: @if command -v cargo-kani >/dev/null 2>&1; then \ diff --git a/lefthook.yml b/lefthook.yml index 147504e..4e5365f 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -56,7 +56,18 @@ pre-push: env: PKG_CONFIG_PATH: /tmp/tauri-pc LIBRARY_PATH: /tmp/tauri-libs:/usr/lib/x86_64-linux-gnu - run: export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH" && cargo test --workspace --all-targets + # WHY nextest not `cargo test`: SQLite has a lock-order inversion in + # unixClose vs unixLock-from-WAL-close that deadlocks when multiple + # Connection handles to the same DB are dropped concurrently. cargo + # nextest's process-per-test isolation eliminates the race; the + # repo-level `no-cargo-test` check (below + just recipe) prevents + # regression. Doctest invocation below stays as `cargo test --doc` + # (nextest doesn't run doctests). + run: export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH" && cargo nextest run --workspace --all-targets + no-cargo-test: + # Enforce the "no cargo test" rule across committed automation. Catches + # accidental reintroduction at push time. See scripts/no-cargo-test.sh. + run: ./scripts/no-cargo-test.sh cargo-doctest: env: PKG_CONFIG_PATH: /tmp/tauri-pc diff --git a/scripts/no-cargo-test.sh b/scripts/no-cargo-test.sh new file mode 100755 index 0000000..3e843d2 --- /dev/null +++ b/scripts/no-cargo-test.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# Enforce: no `cargo test` invocations in committed automation files. +# +# RATIONALE: SQLite has a lock-order inversion in unixClose (GLOBAL→PER-INODE) +# vs unixLock-from-sqlite3WalClose (PER-INODE→GLOBAL) that deadlocks when +# multiple rusqlite::Connection handles to the same DB file are dropped +# concurrently. `cargo test --test-threads=N>1` triggers it in ~20% of runs; +# `cargo test --test-threads=1` STILL triggers it in ~33% of runs because +# the deadlock fires within a single test fn's internal threads. +# +# `cargo nextest run` isolates each #[test] in its own forked process → +# eliminates the concurrent-close race + slow-timeout self-terminates any +# single-test deadlock. +# +# Exempt: `cargo test --doc` and `cargo test --workspace --doc` because +# nextest doesn't run doctests. +# +# See: RESEARCH-sqlite-deadlock.md + CLAUDE.md ("test stack" section) + +# GH #121 (Adopt cargo-nextest). + +set -eu + +# Restrict scan to automation files. Markdown docs are free to mention +# `cargo test` in WHY-comments / explanations. +violations=$(grep -rEn 'cargo test( |$)' \ + --include='*.yml' \ + --include='*.yaml' \ + --include='justfile' \ + --include='Justfile' \ + --include='lefthook.yml' \ + --include='*.sh' \ + --include='*.bash' \ + . 2>/dev/null \ + | grep -v -- '--doc' \ + | grep -v 'scripts/no-cargo-test.sh' \ + || true) + +if [ -n "$violations" ]; then + cat >&2 <