diff --git a/CLAUDE.md b/CLAUDE.md index 439af2e2..8bea50c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ bun run build # webapp first, then cargo — the order Tests need no service container: `test_app()` builds a `Config` over a `TempDir` and the suite drives the same router `main` serves. FFmpeg and ffprobe must be on `PATH`. -The integration suite is eighteen targets under `tests/`, one per subject — `auth`, `scanner`, `identity`, `catalog`, `subsonic_contract`, `subsonic_browse`, `subsonic_fields`, `subsonic_methods`, `native_api`, `sync`, `media`, `service`, `cli`, `scrobbling`, `enrichment`, `uploads`, `canvas`, `migrations` — nearly all of them sharing `tests/support/mod.rs`. `migrations` is the exception and deliberately so: every other target starts from `test_app()`, which builds its database from nothing, and that is exactly the path an upgrade does not take. It seeds a database at each **released** schema, boots the server on it, and reads the rows back through the services — and it is where anything else that needs a database *behind* this binary belongs, such as proving that a key change reads without migrating and copies the pair aside before it does. `autotests = false`, so each is a target only because `Cargo.toml` declares it: a new file under `tests/` is compiled by nothing until a `[[test]]` entry names it, and the count above is worth checking against that list rather than trusted. `support/mod.rs` is the exception that proves it — shared module code, declared nowhere, a target never. File a new test by the surface it exercises, and check that by what it calls rather than by what its name says. +The integration suite is nineteen targets under `tests/`, one per subject — `auth`, `scanner`, `identity`, `catalog`, `subsonic_contract`, `subsonic_browse`, `subsonic_fields`, `subsonic_methods`, `native_api`, `sync`, `media`, `service`, `cli`, `scrobbling`, `enrichment`, `credits`, `uploads`, `canvas`, `migrations` — nearly all of them sharing `tests/support/mod.rs`. `migrations` is the exception and deliberately so: every other target starts from `test_app()`, which builds its database from nothing, and that is exactly the path an upgrade does not take. It seeds a database at each **released** schema, boots the server on it, and reads the rows back through the services — and it is where anything else that needs a database *behind* this binary belongs, such as proving that a key change reads without migrating and copies the pair aside before it does. `autotests = false`, so each is a target only because `Cargo.toml` declares it: a new file under `tests/` is compiled by nothing until a `[[test]]` entry names it, and the count above is worth checking against that list rather than trusted. `support/mod.rs` is the exception that proves it — shared module code, declared nowhere, a target never. File a new test by the surface it exercises, and check that by what it calls rather than by what its name says. `cargo build` works without a client build — `build.rs` embeds a placeholder page when `webapp/dist` is absent. A real `vite build` overwrites it. @@ -60,7 +60,7 @@ The integration suite is eighteen targets under `tests/`, one per subject — `a - **404 blurs everything.** A resource that is missing, and one that belongs to another account, answer identically. `ServiceError::Forbidden` maps onto 404 for that reason. Never confirm existence to someone not entitled to it. - **SQLite discipline.** WAL, foreign keys, `busy_timeout`. Every mutation takes the process-wide writer gate (`db.writer_guard()`); libraries may scan concurrently but never open independent writers. `NULL` sorts first in SQLite — order explicitly with `NULLS LAST` wherever a missing tag would otherwise jump the queue. - **Migrations are immutable once merged.** They are embedded at compile time and checksummed, so editing an applied migration makes the server refuse to start. Schema changes are new dated files under `migrations-v2/`. -- **Identifiers and time.** Public ids are UUIDs; timestamps are Unix epoch milliseconds. Album and artist ids are *derived* UUIDs (version 8, RFC 9562 §5.8): the identity engine in `src/pid.rs` hashes the tags that name the entity, under the configurable spec `WAVEFLOW_PID_ALBUM`/`WAVEFLOW_PID_ARTIST`, so the same files answer the same ids on a fresh install. Track ids stay drawn at random — six tables cascade off them, and a scan matches a track by path, then by content hash, then by the relocation hint in `track.pid`. That hint is the track spec evaluated over the file's tags, and it stays a hint: it is asked only when the first two have nothing to say — a file both moved and re-encoded — and it moves nothing unless it names exactly one track whose file has genuinely disappeared. Changing `WAVEFLOW_PID_TRACK` drops every stored hint on the next boot: a hint written under one spec must never be compared against a lookup computed under another, because two specs can evaluate to the same string and the hint would then hand a new file an unrelated track's identity. The next ordinary scan writes them back from its skip path, so clearing them costs no rescan — and a track spec change still re-identifies nothing. Changing a spec re-identifies everything it governs: the next boot notices, schedules a full rescan of every library, and album favourites do not survive it. +- **Identifiers and time.** Public ids are UUIDs; timestamps are Unix epoch milliseconds. Album and artist ids are *derived* UUIDs (version 8, RFC 9562 §5.8): the identity engine in `src/pid.rs` hashes the tags that name the entity, under the configurable spec `WAVEFLOW_PID_ALBUM`/`WAVEFLOW_PID_ARTIST`, so the same files answer the same ids on a fresh install. Track ids stay drawn at random — six tables cascade off them, and a scan matches a track by path, then by content hash, then by the relocation hint in `track.pid`. That hint is the track spec evaluated over the file's tags, and it stays a hint: it is asked only when the first two have nothing to say — a file both moved and re-encoded — and it moves nothing unless it names exactly one track whose file has genuinely disappeared. Changing `WAVEFLOW_PID_TRACK` drops every stored hint on the next boot: a hint written under one spec must never be compared against a lookup computed under another, because two specs can evaluate to the same string and the hint would then hand a new file an unrelated track's identity. The next ordinary scan writes them back from its skip path, so clearing them costs no rescan — and a track spec change still re-identifies nothing. Changing a spec re-identifies everything it governs: the next boot notices, schedules a full rescan of every library, and album favourites do not survive it. A credit decision (RFC-015) is narrower: splitting a joined credit such as `Zedd, Alessia Cara` re-identifies only the artists it names. The album display string and the track hint read the tag mapper's names, before any decision, so no album or track id moves, and favourites on the joined artist are carried onto its parts through the journal. - **Credentials.** Web passwords use Argon2id. Access, refresh, API and OAuth codes are stored as SHA-256 hashes only. The dedicated Subsonic password is encrypted with ChaCha20-Poly1305 under the instance key. Back up `data/waveflow.db` and `data/instance.key` together — encrypted values cannot be recovered with one without the other. - **Browser playback uses stream tickets** (`src/stream_ticket.rs`). `