docs: resolve TrackDto nesting (§0.3) and add §8.0 wave sequencing

Resolve Phase 8 open question 0.3 — TrackDto gets a nested Release
(ReleaseDto); flat release fields removed, all consumers updated as
part of §8.0 (flat read-model rejected). Add §0.6 implementation
sequencing: five mergeable waves with Waves 1+2 as a single deployment
unit and Waves 3+4 parallelizable. Update PLAN.md §8.0 Shape to match.
This commit is contained in:
daniel-c-harvey
2026-06-11 11:09:24 -04:00
parent 8983592e56
commit 16f356a760
2 changed files with 110 additions and 20 deletions
+2 -2
View File
@@ -138,9 +138,9 @@ Three browse modes for the CMS `/tracks` page — **Track**, **Album**, **Genre*
### 8.0 `TrackEntity` normalization (pre-requisite — must land before §8.1–§8.5) ### 8.0 `TrackEntity` normalization (pre-requisite — must land before §8.1–§8.5)
- **What:** Split the flat `TrackEntity` into two normalized tables. New **`ReleaseEntity`** holds release-cardinal data (`Title`, `Artist`, `Genre?`, `ReleaseDate?`, `ImagePath?`, `ReleaseType`, `CreatedByUserId?`). Slimmed **`TrackEntity`** holds track-cardinal data only (`Id`, `ReleaseId` FK, `Release` nav, `EntryKey`, `TrackName`, `TrackNumber`, `OriginalFileName?`) — the release fields are removed from it. New `ReleaseDto`; `TrackDto` slims and gains `ReleaseId` + a Release access path; `AlbumSummaryDto` is retired in favour of `ReleaseDto`. - **What:** Split the flat `TrackEntity` into two normalized tables. New **`ReleaseEntity`** holds release-cardinal data (`Title`, `Artist`, `Genre?`, `ReleaseDate?`, `ImagePath?`, `ReleaseType`, `CreatedByUserId?`). Slimmed **`TrackEntity`** holds track-cardinal data only (`Id`, `ReleaseId` FK, `Release` nav, `EntryKey`, `TrackName`, `TrackNumber`, `OriginalFileName?`) — the release fields are removed from it. New `ReleaseDto`; `TrackDto` slims and gains `ReleaseId` + a **nested `Release` (`ReleaseDto`)** (resolved 2026-06-11: nested, not a flat read model — flat fields are removed and every consumer is updated, not denormalized back); `AlbumSummaryDto` is retired in favour of `ReleaseDto`.
- **Why:** The flat schema duplicates release-level metadata on every track row — updating an album's cover art or artist means rewriting every track. Phase 8 introduces album-as-a-unit editing (Batch Edit, album-scoped delete), so the model should match the domain: a Release is first-class; Tracks belong to a Release. This collapses several §8 UI open questions (Album-mode parent rows become Release rows directly; no `GROUP BY`-derived summary). - **Why:** The flat schema duplicates release-level metadata on every track row — updating an album's cover art or artist means rewriting every track. Phase 8 introduces album-as-a-unit editing (Batch Edit, album-scoped delete), so the model should match the domain: a Release is first-class; Tracks belong to a Release. This collapses several §8 UI open questions (Album-mode parent rows become Release rows directly; no `GROUP BY`-derived summary).
- **Shape:** Breaking migration — create `releases`, populate from distinct `(album, artist)` groups in `tracks`, add + populate `release_id` FK, drop the redundant track columns. Consumer impact reaches the public client (`DeepDrftPublic.Client` reads the flat `TrackDto` fields for the gallery) as well as the CMS — flagged as the highest-risk consumer in the notes. Open questions: nullable release FK for album-less tracks (recommend yes), upload auto-create-or-find Release (recommend yes), flat-vs-nested `TrackDto` read shape for the public API (recommend flat read model). Full spec + migration steps + consumer-impact list: notes §0. - **Shape:** Sequenced as **five mergeable waves** (notes §0.6): (1) data model — `ReleaseEntity`/config/migration in `DeepDrftData`; (2) DTOs/services/repositories/API — `ReleaseDto`, slimmed `TrackDto`, JOIN-projecting repository, upload find-or-create Release; **Waves 1 + 2 are a single deployment unit** (removing the entity fields breaks compile until the DTO/service layer lands — never merge Wave 1 alone); (3) public-client consumers (`TrackCard`, `TrackDetail`, `TrackMetaLabel`, `NowPlayingCard`) re-point to `track.Release.*`; (4) existing CMS surfaces (`TrackEdit`, `TrackNew`, `BatchUpload`, `TrackList`) minimally updated to compile on the normalized model — Waves 3 + 4 run in parallel; (5) the Phase 8 UI (§8.1–§8.5) begins only after 14 are stable. The breaking migration: create `releases`, populate from distinct `(album, artist)` groups, add + populate `release_id` FK, drop redundant track columns. Remaining open questions for Daniel: nullable release FK for album-less tracks (recommend yes), upload auto-create-or-find Release (recommend yes — committed in Wave 2 shape). Full spec, wave breakdown, and per-file consumer list: notes §0 / §0.6.
### 8.1 URL scheme + mode toggle *(depends on §8.0)* ### 8.1 URL scheme + mode toggle *(depends on §8.0)*
+108 -18
View File
@@ -2,7 +2,9 @@
Status: spec / one VM, three views. Review decisions folded in 2026-06-11 (waveform-in-grid, Status: spec / one VM, three views. Review decisions folded in 2026-06-11 (waveform-in-grid,
DI-scoped VM, BatchEdit extraction confirmed). **A data-model normalization (§0) is now a hard DI-scoped VM, BatchEdit extraction confirmed). **A data-model normalization (§0) is now a hard
pre-requisite — it must land before any Phase 8 UI work.** Author: product-designer. pre-requisite — it must land before any Phase 8 UI work**, sequenced as five mergeable waves
(§0.6). Open Q3 resolved 2026-06-11: `TrackDto` gets a **nested `Release` (`ReleaseDto`)**, flat
release fields removed, all consumers updated as part of §8.0. Author: product-designer.
Date: 2026-06-11. Date: 2026-06-11.
**Plan only — no code edits made by this doc.** **Plan only — no code edits made by this doc.**
@@ -83,22 +85,26 @@ The normalization also collapses several open questions in the UI spec below:
- New `ReleaseDto` mirroring `ReleaseEntity`. - New `ReleaseDto` mirroring `ReleaseEntity`.
- `TrackDto` slims down: remove `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`, - `TrackDto` slims down: remove `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`,
`ReleaseType`, `CreatedByUserId`. Add `ReleaseId` (long) and, for read paths that display release `ReleaseType`, `CreatedByUserId`. Add `ReleaseId` (long) and a **nested `Release` (`ReleaseDto`)**
data alongside track data, either a nested `Release` (`ReleaseDto`) or a flat denormalized read property — populated on reads, ignored on writes where only the FK matters. (Open question 3
model (open question 3). **RESOLVED 2026-06-11**: nested `ReleaseDto`, not a flat read model. See §0.3 below.)
- `AlbumSummaryDto` is retired / collapsed into `ReleaseDto` (open question 4). - `AlbumSummaryDto` is retired / collapsed into `ReleaseDto` (open question 4).
**Consumer-impact list (the most consumer-visible part of this change):** **Consumer-impact list (the most consumer-visible part of this change):**
The existing `GET api/track/page` response shape changes. Callers that read `TrackDto.Artist` / The existing `GET api/track/page` response shape changes. Callers that read `TrackDto.Artist` /
`.Album` / `.Genre` / `.ReleaseDate` / `.ImagePath` must adjust to read those from the Release `.Album` / `.Genre` / `.ReleaseDate` / `.ImagePath` / `.ReleaseType` must adjust to read those from
(`TrackDto.Release.Artist` / `.Release.Title` / …) or from a flat joined read model. Known the nested Release: `TrackDto.Release.Artist`, `.Release.Title` (was `.Album`), `.Release.Genre`,
consumers: `.Release.ReleaseDate`, `.Release.ImagePath`, `.Release.ReleaseType`. **This is not a "keep the flat
fields populated" no-op — the flat fields are removed and every reader is updated.** Known consumers
(full per-file enumeration in §0.6 below):
- **`DeepDrftPublic.Client`** — the public track gallery (`TrackCard` and the views feeding it) reads - **`DeepDrftPublic.Client`** — the public track gallery (`TrackCard`, `TrackDetail`,
`Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath` for display. **This is the highest-risk `TrackMetaLabel`, `NowPlayingCard`) reads `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`
consumer** — a public-facing surface, a different host, not part of the CMS phase otherwise. for display. **This is the highest-risk consumer** — a public-facing surface, a different host,
- **`DeepDrftManager`** — the CMS (this phase's surface). not part of the CMS phase otherwise. Updated in Wave 3 (§0.6).
- **`DeepDrftManager`** — the CMS (this phase's surface). The existing `TrackEdit` / `TrackNew` /
`BatchUpload` forms read/write the flat fields. Updated in Wave 4 (§0.6).
**Service / repository impact (list, not prescription):** **Service / repository impact (list, not prescription):**
@@ -119,11 +125,15 @@ consumers:
or does the endpoint auto-create a Release if none exists for the given album name? or does the endpoint auto-create a Release if none exists for the given album name?
**Recommend auto-create-or-find** (upsert on `(title, artist)`) — keeps upload-form ergonomics **Recommend auto-create-or-find** (upsert on `(title, artist)`) — keeps upload-form ergonomics
close to today's. close to today's.
3. **`TrackDto` shape for the public API.** The public gallery reads flat `Artist` / `Album` / 3. **~~`TrackDto` shape for the public API.~~ — RESOLVED 2026-06-11.** `TrackDto` gets a **nested
`Genre` / `ReleaseDate` / `ImagePath`. Keep these queryable post-normalization via either a flat `Release` (`ReleaseDto`)** property; the release-cardinal flat fields (`Artist`, `Album`, `Genre`,
joined read model (no nested object) or a nested `Release` property. **Recommend the flat `ReleaseDate`, `ImagePath`, `ReleaseType`, `CreatedByUserId`) are **removed** from `TrackDto` and
read-model for the public API** — no response-shape break if the flat fields stay populated from live only on `ReleaseDto`. The flat read-model alternative is **rejected** — denormalizing the
the join. Call this out as the most consumer-visible impact. release fields back onto every `TrackDto` would re-introduce exactly the duplication §0
normalizes away, just at the contract layer instead of the table layer. All consumers that read
the flat fields are updated as part of §8.0 (Waves 3 + 4, §0.6) — this is mandatory, not
follow-on. This is the most consumer-visible impact of the phase; the consumer enumeration is
§0.6.
4. **`AlbumSummaryDto` fate.** With a Release table, `GetAlbumSummariesAsync` becomes 4. **`AlbumSummaryDto` fate.** With a Release table, `GetAlbumSummariesAsync` becomes
`GetReleasesAsync` returning `List<ReleaseDto>`. **Recommend retiring `AlbumSummaryDto`** in favor `GetReleasesAsync` returning `List<ReleaseDto>`. **Recommend retiring `AlbumSummaryDto`** in favor
of `ReleaseDto` — a direct replacement. of `ReleaseDto` — a direct replacement.
@@ -147,6 +157,86 @@ where they mention `AlbumSummaryDto` widening or deriving release fields from ch
as resolved by §0** — the Release table supplies those fields directly. Net of §0, those UI as resolved by §0** — the Release table supplies those fields directly. Net of §0, those UI
sections get *simpler*, not harder. sections get *simpler*, not harder.
### 0.6 Implementation sequencing — appropriately sized waves
§8.0 is a breaking data-model change with a wide consumer blast radius, so it is sequenced into
independently-mergeable waves. The unit of mergeability is: *can this wave land on `dev` without
breaking the next wave's prerequisites?* Two waves (1 + 2) are a forced exception — flagged below.
**Wave 1 — Data model layer (no app-code changes yet).** *Touches only `DeepDrftData`.*
- `ReleaseEntity` (new). `TrackEntity` updated: add `ReleaseId` FK + `Release` navigation property,
remove the release-cardinal fields (`Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`,
`ReleaseType`, `CreatedByUserId`).
- `ReleaseConfiguration` (EF Core `IEntityTypeConfiguration`): table name, column names, FK
relationship, indexes. `TrackConfiguration` updated for the FK + removed columns.
- EF Core migration: create `releases` table; add `release_id` FK to `tracks`; **data migration**
populate `releases` from distinct `(album, artist)` groups in existing `tracks` rows, then assign
the FK back to each track; finally drop the now-redundant columns from `tracks`.
- **CRITICAL — Wave 1 + Wave 2 are a single deployment unit.** Removing the entity fields breaks
every DTO mapper, repository projection, and service that reads them — the solution will not
compile at the end of Wave 1 alone. Wave 1 and Wave 2 must be developed and merged to `dev`
**together** (one PR, or a short-lived branch merged as a unit) before anything is built or run.
Do not attempt to land Wave 1 on its own.
**Wave 2 — DTOs, services, repositories, API layer.** *`DeepDrftModels`, `DeepDrftData`,
`DeepDrftContent`, `DeepDrftAPI`.* Deploys together with Wave 1.
- New `ReleaseDto` (mirrors `ReleaseEntity`).
- `TrackDto`: remove flat release fields; add `ReleaseId` (long) + `Release` (`ReleaseDto`,
populated on reads, ignored on writes where only the FK matters).
- `TrackRepository` (`GetPaged` / `GetById` / `Update` / etc.): queries JOIN `releases` and project
into the updated `TrackDto` with nested `Release`. Sort-by-release-field (artist/album/genre/date)
now sorts through the join.
- `TrackManager` / `ITrackService`: signature updates where the changed fields surface.
- `UnifiedTrackService.UploadAsync`: resolve a Release (**find-or-create by title + artist**) before
inserting the Track. The upload form still sends album/artist at the top level; the service layer
resolves the Release FK. (This is open question 2's recommended upsert, now committed.)
- `TrackController` endpoints: request/response shapes updated where they touch the changed fields.
- `AlbumSummaryDto` retired; `GetAlbumSummariesAsync` returns `List<ReleaseDto>` (rename to
`GetReleasesAsync`). `ICmsTrackService.GetAlbumSummariesAsync` replaced (or aliased) accordingly.
**Wave 3 — Consumer updates: `DeepDrftPublic.Client` (public site).** Can run in parallel with
Wave 4. Read off the nested `Release` everywhere the flat fields were read. Files verified against
source 2026-06-11:
| File | Flat fields read today | Change |
|---|---|---|
| `Controls/TrackCard.razor` | `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath` (grid + row modes) | → `TrackModel.Release.Artist` / `.Release.Title` / `.Release.Genre` / `.Release.ReleaseDate` / `.Release.ImagePath` |
| `Pages/TrackDetail.razor` | `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath` (masthead, cover, meta block, `hasMeta` guard) | same nested re-pointing |
| `Controls/AudioPlayerBar/TrackMetaLabel.razor` | `Artist`, `Genre`, `ReleaseDate` (player-bar label) | → `Track.Release.*` |
| `Controls/NowPlayingCard.razor` | `CurrentTrack.Artist`, `CurrentTrack.Album` | → `CurrentTrack.Release.Artist` / `.Release.Title` |
- `TrackCard.razor.cs`, `TracksGallery.razor.cs`, `TrackDetailViewModel.cs`,
`TrackDetail.razor.cs`: pass `TrackDto` through but **do not read the flat fields** — no change
needed beyond recompiling against the new contract. Listed so the implementer confirms rather than
assumes.
- **Not flat-field consumers (do not confuse):** `Pages/AlbumsView.razor` reads
`AlbumSummaryDto.Album` and `Pages/GenresView.razor` reads `GenreSummaryDto.Genre` — these are
*summary* DTOs, affected by the `AlbumSummaryDto` retirement in Wave 2, not by the `TrackDto`
flat-field removal. Reconcile them when Wave 2 settles the summary-DTO shape.
**Wave 4 — Consumer updates: `DeepDrftManager` (existing CMS surfaces).** Can run in parallel with
Wave 3. These must work against the normalized model *before* the Phase 8 UI work (Wave 5) starts.
| File | Today | Change |
|---|---|---|
| `Components/Pages/Tracks/TrackEdit.razor` | `TrackEditForm.From(track)` reads `track.Artist/.Album/.Genre/.ImagePath/.ReleaseDate/.ReleaseType`; `SaveAsync` calls `UpdateAsync` with the flat args | read from `track.Release.*`; `UpdateAsync` path follows Wave 2's signature (still passes album/artist, service resolves the Release) |
| `Components/Pages/Tracks/TrackNew.razor` | binds `_album`/`_artist`/`_genre`/`_releaseDate`, calls `UploadTrackAsync` + cover-link `UpdateAsync` | form fields can stay; the upload service now auto-resolves the Release (Wave 2). Binding unchanged externally, resolution changes internally |
| `Components/Pages/Tracks/BatchUpload.razor` | album-header flat fields sent on upload | same — service auto-creates/finds the Release; form fields stay, internal binding adjusts |
| `Components/Pages/Tracks/TrackList.razor` | the `MudTable<TrackDto>` columns bind `Artist`/`Album`/`Genre`/`ReleaseDate` | re-point to `.Release.*`. **Note:** this grid is superseded by §8.2 `CmsTrackGrid` in Wave 5 — update it minimally in Wave 4 only to keep `dev` green, since Wave 5 rebuilds it |
- Most CMS surfaces touched here are *rebuilt* in Wave 5 (Phase 8 UI). Wave 4's job is the minimum
to keep the existing CMS compiling and functional on the normalized model — not to polish surfaces
that Phase 8 replaces. Flag in each PR which edits are throwaway-pending-Wave-5 vs. durable.
**Wave 5 — Phase 8 UI (the original §8.1–§8.5).** Begins only after Waves 14 are merged and the
normalized model is stable on `dev`. `CmsTrackGrid`, `CmsAlbumBrowser`, `CmsGenreBrowser`,
`BatchEdit`, the mode toggle, and routing — all the UI work specced in §1–§13 below. The internal
sequencing *within* Wave 5 is §13.
**Dependency summary:** `[1 + 2 together] → {3, 4 in parallel} → 5`.
--- ---
## 1. Summary ## 1. Summary
@@ -662,8 +752,8 @@ the confirm copy unambiguous about the blast radius.
| Contract | Change | Why | Risk | | Contract | Change | Why | Risk |
|---|---|---|---| |---|---|---|---|
| **`TrackEntity` / `ReleaseEntity`** (§0) | **Split** flat `TrackEntity` into `ReleaseEntity` + slim `TrackEntity` | normalization; pre-requisite gate | **High — breaking migration; see §0** | | **`TrackEntity` / `ReleaseEntity`** (§0) | **Split** flat `TrackEntity` into `ReleaseEntity` + slim `TrackEntity` | normalization; pre-requisite gate | **High — breaking migration; see §0** |
| **`TrackDto`** (§0) | Slim down (drop release fields, add `ReleaseId` + Release access path) | follows the entity split | High — touches public + CMS consumers (§0.3) | | **`TrackDto`** (§0) | Slim down (drop release flat fields, add `ReleaseId` + **nested `Release` (`ReleaseDto`)**) | follows the entity split; nested not flat (open Q3 resolved 2026-06-11) | High — touches public + CMS consumers; all updated in Waves 3 + 4 (§0.3, §0.6) |
| **`ReleaseDto`** (§0) | **New**, mirrors `ReleaseEntity`; retires `AlbumSummaryDto` | Album mode reads the Release table directly | Medium | | **`ReleaseDto`** (§0) | **New**, mirrors `ReleaseEntity`; retires `AlbumSummaryDto` | Album mode reads the Release table directly; also the nested read object on `TrackDto` | Medium |
| `ICmsTrackService.GetPagedAsync` | **Add** `string? album = null, string? genre = null` params (or an overload) | All three modes' filtered reads route through here; endpoint already supports the query filters. Post-§0 the filter joins through `releases` | Low — additive, default-null keeps callers compiling | | `ICmsTrackService.GetPagedAsync` | **Add** `string? album = null, string? genre = null` params (or an overload) | All three modes' filtered reads route through here; endpoint already supports the query filters. Post-§0 the filter joins through `releases` | Low — additive, default-null keeps callers compiling |
| `CmsTrackService` (impl) | Pass `album`/`genre` through to the `api/track/page` query string | wire the above | Low | | `CmsTrackService` (impl) | Pass `album`/`genre` through to the `api/track/page` query string | wire the above | Low |
| `TrackDto.HasWaveformProfile` (bool) | **Add** (recommended over a second lookup, §8 item 7) | in-grid waveform status column without per-page fan-out | Low — additive; fold into the §0 DTO pass | | `TrackDto.HasWaveformProfile` (bool) | **Add** (recommended over a second lookup, §8 item 7) | in-grid waveform status column without per-page fan-out | Low — additive; fold into the §0 DTO pass |