crabidy/architecture/orphans.md

243 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# The `orphans` provider — a store garbage-collection view
## Context and problem statement
The content-addressed store (`architecture/crabidy-store.md`) never shrinks on
its own. Capturing writes audio + a `<name>.cbd-store.toml` sidecar into
`~/.local/share/crabidy/`; deleting a save (or the `current` queue rolling over,
or a `scan --capture` toml being removed) only ever removes the *toml that
pointed at* a store entry — never the entry itself (store `D7`). That is
deliberate: a store entry may be shared by many tomls, so no single deletion can
know it is safe to reclaim. The consequence, called out as future work in store
`D10`, is that store entries accumulate that **no toml references any more**.
There is today no way to see them or reclaim their disk.
This design adds a read-mostly management provider, **`/orphans`**, that surfaces
exactly those unreferenced store entries and lets the user rename or delete them,
or queue them for a listen before deciding. It is the store's garbage-collection
UI, expressed as an ordinary library subtree so it needs no new client concepts.
This **realizes** store `D10` (orphan reclamation). It changes nothing about how
captures are written or de-duplicated; it only *reads* the store's residue and
offers targeted rename/delete.
## Assumptions
- **Confirmed by the request.** `/orphans` lists every store item, walks all
local file providers, crosses off referenced items, and presents the rest;
entries are renamable (audio file *and* sidecar), deletable (files on disk),
and queueable.
- **"Referenced" means reachable through a mounted local file provider.** The
reference scan walks the disk roots of the running file providers — the
`/crabidy` toml tree (`~/.local/state/crabidy/`, which holds `current`, every
save, every capture) and the `/fs` root (which can hold `Playable::Store`
tomls written by `scan --capture`/`--move`). A `.cbd-track.toml` that lives
*outside* every mounted provider root (e.g. `scan --capture` run on a folder
that is not under `/fs`) is invisible to the walk, so its target counts as an
orphan. This is the only sound definition available without a global
reference index, and it matches the request's wording ("walks all local file
providers"). It is a documented boundary, not a bug (see Risks).
- **Single writer.** As with the rest of the store, one process owns both roots
and serializes mutations under the store's index mutex; there is no concurrent
external writer.
- **Orphan-ness is recomputed on every listing.** There is no persisted orphan
list — consistent with `fsdy`'s "read the tree fresh every visit" philosophy.
A capture that adds a reference makes an entry stop being an orphan on the
next listing.
## What is a "store item" and when is it an orphan
- A **store item** is a pair in `store_root`: an audio file `<name>` and its
sidecar `<name>.cbd-store.toml`. The sidecar is the source of truth (store
`D2`); the set of items is the set of sidecars that have a readable audio file
beside them. (A sidecar without audio, or audio without a sidecar, is
malformed residue — reported so it can be reclaimed too; see D4.)
- A store item `<name>` is **referenced** iff some `.cbd-track.toml` under a
mounted file-provider root validates to `Playable::Store(<name>)`.
- **Orphans = all store items referenced store items.**
Because `/orphans` only ever exposes unreferenced entries, renaming or deleting
one **cannot break any toml reference** — that is what makes the destructive
operations safe by construction (subject to the narrow race in Risks).
## Options considered
### Presentation: tracks vs. child nodes
Each orphan must be renamable, deletable, and queueable. The wire has two
carriers with capability flags:
- **`Track`** — has `is_captured`/`is_skipped` but **no** `is_editable`. Tracks
can be deleted (`tracks_deletable`) and queued, but the library has **no
rename-a-track gesture** anywhere; adding one means new proto surface plus TUI
and web changes.
- **`LibraryNodeChild`** — already carries `is_editable`, `is_deletable`,
`is_queable`, `is_captured`. The node-editing feature already binds `e`
`rename_lib_node(child_path, new_title)` and `d``delete_lib_node(child_path)`
for children that advertise the flags (that is how `/tidal/search` terms and
`/crabidy` saves are renamed/deleted today), and queueing a queueable child
resolves its tracks.
**Decision: present each orphan as an editable + deletable + queueable child
node** of `/orphans`, titled by its store file `<name>` (the thing a rename
edits). Entering the node lists its single track (the store audio, with metadata
from the sidecar); queueing the node — or the whole `/orphans` root — resolves
that track. This reuses `rename_lib_node`/`delete_lib_node`/`resolve_tracks_into`
and every client gesture **with zero proto, TUI, or web changes**. The only
minor wart — each orphan is a one-track "folder" — is acceptable and is exactly
how a single-track save already presents. The track-carrier option was rejected
purely on cost: it buys nothing the node model lacks and forces a wire change
just to gain a rename gesture.
### Home of the logic: new crate vs. server module
The orphan computation needs the store root and index (to enumerate items and
mutate them) *and* the file-provider disk roots (to find references). A standalone
`orphandy` crate would have to duplicate `CrabidyStore` internals it does not own.
**Decision: keep it in `crabidy-server`.** Orphan enumeration, rename, and delete
become methods on `CrabidyStore` (it already owns `store_root` and the index). A
thin new `OrphansProvider` (`crabidy-server/src/orphans.rs`) implements
`ProviderClient`, holding `Arc<CrabidyStore>` plus the list of reference roots to
walk, and delegates to those store methods. `ProviderOrchestrator` mounts and
routes `/orphans` exactly like the other providers.
## Boundaries and interfaces
```d2
direction: right
tui: TUI / web / cbd-cli { shape: person }
orchestrator: ProviderOrchestrator {
routes: "routes /orphans/*"
}
orphans: OrphansProvider {
refroots: "ref_roots: Vec<PathBuf>"
}
store: CrabidyStore {
index: "StoreIndex (by_hash / by_provider_id)"
ops: "list_orphans / rename_orphan / delete_orphan"
}
data: content store\n~/.local/share/crabidy {
shape: cylinder
items: "<name> + <name>.cbd-store.toml"
}
crabidytree: /crabidy tree\n~/.local/state/crabidy { shape: cylinder }
fstree: /fs root { shape: cylinder }
tui -> orchestrator: "get / rename / delete / queue /orphans/*"
orchestrator -> orphans: delegate
orphans -> store: "enumerate + mutate (by name)"
store -> data: "read sidecars, rename/delete files"
orphans -> crabidytree: "walk for Playable::Store refs"
orphans -> fstree: "walk for Playable::Store refs"
```
### The orphan diff (what a listing computes)
```d2
direction: down
allitems: "all store items\n(scan *.cbd-store.toml in store_root)"
refs: "referenced set\n(walk ref_roots for\nPlayable::Store(name))"
diff: "orphans = all referenced" { shape: diamond }
node: "/orphans node:\none editable/deletable/queueable\nchild per orphan"
allitems -> diff
refs -> diff
diff -> node
```
### Provider surface (`OrphansProvider: ProviderClient`)
Mounted at `/orphans` only when `crabidy_store` is present (it is the store's
view). Paths: the root `/orphans`, and one child per orphan at
`/orphans/<encode_segment(name)>`. There are no deeper levels.
- `get_lib_root` / `get_lib_node("/orphans")` — a queueable, non-creatable node
whose children are the current orphans (recomputed by the diff above). Each
child: `title = <name>`, `is_queable = true`, `is_editable = true`,
`is_deletable = true`, `is_downloadable = false`, `is_captured = true`.
- `get_lib_node("/orphans/<seg>")` — a queueable, childless node carrying the
single `Track` for that store entry (metadata from the sidecar's first
provider entry; `is_captured = true`). Unknown/renamed-away segment →
`MalformedPath`.
- `is_track_path` — always `false`: orphans are addressed as nodes, and the one
track is reached by resolving the node (so the default `resolve_tracks_into`
walk queues it). `get_metadata_for_track` therefore is not the entry point;
`get_urls_for_track("/orphans/<seg>")` returns `store_root/<name>` (a local
file path, exactly like a resolved `Playable::Store`) so the resolved track
still plays.
- `rename_lib_node("/orphans/<seg>", new)` — validates `new` as a bare store
file name (`validate_folder_name(new, &[])`: non-empty, no separators/NUL, no
leading dot), refuses a name already taken by another store entry
(`InvalidInput`), then renames **both** `<old>`→`<new>` audio and
`<old>.cbd-store.toml`→`<new>.cbd-store.toml`, and updates the in-memory index
(drop the old name's mappings, re-insert under the new name; hash and provider
ids are unchanged). Returns the renamed node at `/orphans/<encode(new)>`.
- `delete_lib_node("/orphans/<seg>")` — removes the audio file and the sidecar
from `store_root` and drops the entry from the index; returns the refreshed
`/orphans` root. Idempotent (an already-gone entry succeeds).
- `create_lib_node``NotSupported` (the root is not creatable).
### Store methods added to `CrabidyStore`
- `list_orphans(&self, ref_roots: &[PathBuf]) -> Result<Vec<OrphanEntry>, StoreError>`
— scan `store_root` for `*.cbd-store.toml`; build the referenced set by walking
each `ref_root` recursively for `*.cbd-track.toml` and collecting
`Playable::Store(name)`; return the difference as `OrphanEntry { name, title,
artist, duration, album }` (metadata from the sidecar's first provider entry).
- `orphan_track(&self, name) -> Result<Track, StoreError>` /
`orphan_url(&self, name) -> Result<String, StoreError>` — build the wire track
/ resolve the store audio path for a single entry.
- `rename_orphan(&self, old, new)` / `delete_orphan(&self, name)` — the mutations
above, under the index mutex, with the index kept in sync.
- `StoreIndex::remove(&mut self, name, sidecar)` — the inverse of `insert`, so
rename/delete can update the derived index without a full rescan.
### Reference roots wiring
`OrphansProvider` is constructed in `ProviderOrchestrator::init` with
`ref_roots` = the disk roots of the mounted file providers: the `/crabidy` tree
(`store.tree_dir()`) and, when enabled, the `/fs` root. A new
`fsdy::Client::disk_root(&self) -> &Path` accessor exposes the `/fs` root (the
`/crabidy` tree root is already available via `CrabidyStore::tree_dir`). If more
`fsdy` instances are ever mounted, they are added to this list — the definition
of "local file provider" is "an `fsdy` instance whose root can hold store
references."
## Risks and open questions
- **Capture-then-delete race (TOCTOU).** Between a `/orphans` listing and a
delete, a concurrent `W` capture could hash-hit the very entry the user is
about to delete and write a fresh `Playable::Store` reference to it; deleting
then leaves that new toml dangling. The window is small (store mutations
serialize under the index mutex and orphan-ness is recomputed every listing),
and the failure is benign: a dangling store reference already resolves to
`MalformedPath` at play time and is skipped, not a crash. Accepted; noted here
rather than engineered away.
- **References outside mounted roots are not counted.** As stated in Assumptions,
a `scan --capture` toml under a folder that is not mounted under `/fs` will not
be seen, so its target shows as an orphan. Deleting it would orphan that
toml's audio. The mitigation is scope discipline (scan under `/fs`); the
alternative — a persisted global reference index — is out of scope and would
fight the "read fresh" design.
- **Cost.** A `/orphans` listing scans the whole store plus both trees on every
visit (no cache), i.e. O(store entries + tomls under the roots). This matches
`fsdy`'s existing per-visit read cost and is fine for personal-library sizes;
if it ever bites, memoizing behind the index's mutation counter is the escape
hatch. Not premature-optimized here.
- **Malformed residue.** A sidecar with no audio (or vice versa) is itself
reclaimable junk. `list_orphans` reports such half-entries as orphans (titled
by whatever is present) so a delete cleans them up; it never treats a
half-entry as "referenced."
- **Open question:** should the `/orphans` root also expose a single "delete all"
affordance? Deferred — per-item delete covers the request; bulk reclaim can be
a later addition (a client could multi-select and delete, once marks exist
there).