feat(core): tenant-scoped PostgresLibraryRepository (Phase 1.b.5a) - #185
Conversation
Same shape as the profile work in PRs #181 / #183 / #184: server-only inherent methods that scope every query to both `profile_id` (the resource's owning profile) and `user_id` (the request's authenticated user). The single-tenant `LibraryRepository` trait stays untouched on the desktop side, and `PostgresLibraryRepository` deliberately does NOT implement it — a careless `Box<dyn LibraryRepository>` over the Postgres backend would otherwise let user A walk user B's libraries. Methods (5): - `list_for_profile(profile_id, user_id)` — MRU-first, empty list when the user doesn't own the profile (no tenancy leak, no auth pre-check round-trip) - `get_for_profile(id, profile_id, user_id)` — single row, `None` blurs missing / foreign-profile / foreign-user - `insert_for_profile(draft, profile_id, user_id)` — `INSERT ... SELECT FROM profile WHERE id = $1 AND user_id = $7`, returns the inserted row via `RETURNING *` so the caller skips a follow-up SELECT (same race elimination as PR #184's rename_for_user) - `update_for_profile(id, patch, now_ms, profile_id, user_id)` — COALESCE partial update, `UPDATE ... RETURNING *` for the same reason - `delete_for_profile(id, profile_id, user_id)` — `EXISTS` clause on profile validates ownership without a separate join Every SQL statement encodes the (profile_id, user_id) ownership pair in its WHERE clause so the storage layer is the single point of enforcement — no convention-by-comment, no handler-discipline gap. Domain: - `Library` gains `profile_id: i64` with `#[sqlx(default)]`, mirroring `Profile.user_id`. Desktop SELECTs that omit the column (no `profile_id` on the per-profile SQLite `library` table) still round-trip cleanly via the default. The lone desktop call site (`commands/library::create_library`) now sets `profile_id: 0` explicitly to match. Counts (`track_count`, `album_count`, `artist_count`, `genre_count`, `folder_count`) are stubbed at `0::bigint` in every SELECT for this phase; they become real aggregates as tracks / albums / playlists land in 1.b.5b+, without changing the wire shape. Schema lives in `waveflow-server/migrations/` (next PR): `library.profile_id BIGINT NOT NULL REFERENCES profile(id) ON DELETE CASCADE` + the usual indices. Zero behaviour change on the desktop. Validated: workspace check + clippy + 111 tests pass.
📝 WalkthroughWalkthroughCe PR ajoute un modèle de tenancy multi-tenant à la structure ChangesTenancy Model et Repository Postgres Tenant-Scoped
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Summary
Phase 1.b.5a — give `waveflow-server` the tenant-scoped surface for the second resource family (library). Same shape as PRs #181 / #183 / #184 for profile.
Strategy
`PostgresLibraryRepository` carries inherent methods only — does not implement `LibraryRepository`. The trait is single-tenant (no `user_id` parameter); exposing it on a multi-tenant Postgres backend would let a careless caller bypass the user_id filter.
Every method takes both `profile_id` (the owning profile) AND `user_id` (the authenticated user). The SQL encodes the ownership pair (`library.profile_id` ↔ `profile.user_id`) in its WHERE clause, so storage is the single point of enforcement — no convention-by-comment.
Domain
`Library` gains `profile_id: i64` with `#[sqlx(default)]` — mirrors how `Profile.user_id` round-trips on the desktop SQLite that has no column for it. The lone desktop call site (`commands/library::create_library`) sets `profile_id: 0` explicitly.
Counts (`track_count`, `album_count`, …) are stubbed at `0::bigint` in every SELECT — they become real aggregates as tracks / playlists land in 1.b.5b+, without changing the wire shape.
Out of scope
Test plan
Summary by CodeRabbit
Chores