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
+108 -18
View File
@@ -2,7 +2,9 @@
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
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.
**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`.
- `TrackDto` slims down: remove `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`,
`ReleaseType`, `CreatedByUserId`. Add `ReleaseId` (long) and, for read paths that display release
data alongside track data, either a nested `Release` (`ReleaseDto`) or a flat denormalized read
model (open question 3).
`ReleaseType`, `CreatedByUserId`. Add `ReleaseId` (long) and a **nested `Release` (`ReleaseDto`)**
property — populated on reads, ignored on writes where only the FK matters. (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).
**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` /
`.Album` / `.Genre` / `.ReleaseDate` / `.ImagePath` must adjust to read those from the Release
(`TrackDto.Release.Artist` / `.Release.Title` / …) or from a flat joined read model. Known
consumers:
`.Album` / `.Genre` / `.ReleaseDate` / `.ImagePath` / `.ReleaseType` must adjust to read those from
the nested Release: `TrackDto.Release.Artist`, `.Release.Title` (was `.Album`), `.Release.Genre`,
`.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
`Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath` for display. **This is the highest-risk
consumer** — a public-facing surface, a different host, not part of the CMS phase otherwise.
- **`DeepDrftManager`** — the CMS (this phase's surface).
- **`DeepDrftPublic.Client`** — the public track gallery (`TrackCard`, `TrackDetail`,
`TrackMetaLabel`, `NowPlayingCard`) reads `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`
for display. **This is the highest-risk consumer** — a public-facing surface, a different host,
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):**
@@ -119,11 +125,15 @@ consumers:
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
close to today's.
3. **`TrackDto` shape for the public API.** The public gallery reads flat `Artist` / `Album` /
`Genre` / `ReleaseDate` / `ImagePath`. Keep these queryable post-normalization via either a flat
joined read model (no nested object) or a nested `Release` property. **Recommend the flat
read-model for the public API** — no response-shape break if the flat fields stay populated from
the join. Call this out as the most consumer-visible impact.
3. **~~`TrackDto` shape for the public API.~~ — RESOLVED 2026-06-11.** `TrackDto` gets a **nested
`Release` (`ReleaseDto`)** property; the release-cardinal flat fields (`Artist`, `Album`, `Genre`,
`ReleaseDate`, `ImagePath`, `ReleaseType`, `CreatedByUserId`) are **removed** from `TrackDto` and
live only on `ReleaseDto`. The flat read-model alternative is **rejected** — denormalizing the
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
`GetReleasesAsync` returning `List<ReleaseDto>`. **Recommend retiring `AlbumSummaryDto`** in favor
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
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
@@ -662,8 +752,8 @@ the confirm copy unambiguous about the blast radius.
| Contract | Change | Why | Risk |
|---|---|---|---|
| **`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) |
| **`ReleaseDto`** (§0) | **New**, mirrors `ReleaseEntity`; retires `AlbumSummaryDto` | Album mode reads the Release table directly | Medium |
| **`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; 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 |
| `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 |