Allow song-specific mix names - #45
Conversation
- Persist mix name overrides per song - Show overridden names in song editing and live views
There was a problem hiding this comment.
1 issue found across 14 files
Confidence score: 3/5
- In
packages/backend/src/songs/SongService.ts, editing fails when a song has a mix-name override tied to a soft-deleted mix becausefindMixNamesinShowRepositorypulls stalesong_mix_namesrows without filtering; this creates a concrete user-facing regression where affected songs become uneditable — filter out soft-deleted mixes (or ignore orphaned overrides) when loading mix names.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/backend/src/songs/SongService.ts">
<violation number="1" location="packages/backend/src/songs/SongService.ts:146">
P2: A song that has a stored mix-name override for a mix that has since been soft-deleted can no longer be edited. `findMixNames` in `ShowRepository` loads every `song_mix_names` row for the show's songs without filtering by the mix's `deleted_at`, so `loadDocument` returns those overrides in `song.mixNames`. When the client echoes them back on the next edit, this validation rejects the entire request because the referenced mix is no longer active. The stale override is only ever cleaned up by a successful edit that no longer references it, so the song can be effectively stuck unless the client filters deleted mixes out of the payload. Consider filtering mix-name overrides to active mixes when loading (or dropping them from `params` validation) so a deleted mix does not block edits to unrelated fields.</violation>
</file>
Architecture diagram
sequenceDiagram
participant UI as SongDetail (Route)
participant RPC as RpcClient
participant Handler as RPC Handler (songs.edit)
participant Sync as Sync Manager
participant SongSvc as SongService
participant ShowRepo as ShowRepository
participant DB as SQLite Database
Note over UI,DB: Song-Specific Mix Name Flow
UI->>UI: User edits mix name in ChannelNameEditor
UI->>UI: Trim input, compare to inherited mix name
alt Override differs from inherited name
UI->>UI: Build mixNames array with override
else Override matches inherited or is empty
UI->>UI: Remove override from mixNames array
end
UI->>RPC: songs.edit({ showId, id, mixNames, ... })
RPC->>Handler: RPC call with payload
Handler->>Sync: sync.mutation(songsSyncKey, ...)
Sync->>SongSvc: edit({ showId, id, mixNames, ... })
SongSvc->>ShowRepo: load show document
ShowRepo->>DB: SELECT mixes, songs, ...
DB-->>ShowRepo: Return mixes with inherited names
SongSvc->>SongSvc: Validate mixIds exist in active mixes
SongSvc->>SongSvc: For each mix, compute effective name
Note over SongSvc: effectiveName = override if non-empty AND differs from inherited, else omit
alt Validation fails (invalid mixId)
SongSvc-->>Sync: RpcError("A named mix is invalid...")
Sync-->>Handler: Error
Handler-->>RPC: RpcError
RPC-->>UI: Save error
else Validation passes
SongSvc->>ShowRepo: save song with updated mixNames
ShowRepo->>DB: DELETE existing song_mix_names for this song_id
ShowRepo->>DB: INSERT new song_mix_names rows
DB-->>ShowRepo: Success
ShowRepo-->>SongSvc: Updated Song document with mixNames
SongSvc-->>Sync: Song (includes mixNames if non-empty)
Sync-->>Handler: Song
Handler-->>RPC: Song
RPC-->>UI: Updated song data
Note over UI: Live Song View Resolution (separate request)
participant LiveView as LiveSongView
participant LegacyUI as LiveSong Component
LegacyUI->>LiveView: projectLiveSong(song, ...)
LiveView->>LiveView: Build mixOverrides map from song.mixNames
LiveView-->>LegacyUI: Projected mixes with song-specific names
end
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| } | ||
| if ( | ||
| new Set(params.mixNames.map((item) => item.mixId)).size !== params.mixNames.length || | ||
| params.mixNames.some((item) => !activeMixIds.has(item.mixId)) |
There was a problem hiding this comment.
P2: A song that has a stored mix-name override for a mix that has since been soft-deleted can no longer be edited. findMixNames in ShowRepository loads every song_mix_names row for the show's songs without filtering by the mix's deleted_at, so loadDocument returns those overrides in song.mixNames. When the client echoes them back on the next edit, this validation rejects the entire request because the referenced mix is no longer active. The stale override is only ever cleaned up by a successful edit that no longer references it, so the song can be effectively stuck unless the client filters deleted mixes out of the payload. Consider filtering mix-name overrides to active mixes when loading (or dropping them from params validation) so a deleted mix does not block edits to unrelated fields.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/backend/src/songs/SongService.ts, line 146:
<comment>A song that has a stored mix-name override for a mix that has since been soft-deleted can no longer be edited. `findMixNames` in `ShowRepository` loads every `song_mix_names` row for the show's songs without filtering by the mix's `deleted_at`, so `loadDocument` returns those overrides in `song.mixNames`. When the client echoes them back on the next edit, this validation rejects the entire request because the referenced mix is no longer active. The stale override is only ever cleaned up by a successful edit that no longer references it, so the song can be effectively stuck unless the client filters deleted mixes out of the payload. Consider filtering mix-name overrides to active mixes when loading (or dropping them from `params` validation) so a deleted mix does not block edits to unrelated fields.</comment>
<file context>
@@ -139,6 +141,14 @@ const make = Effect.fnUntraced(function* () {
}
+ if (
+ new Set(params.mixNames.map((item) => item.mixId)).size !== params.mixNames.length ||
+ params.mixNames.some((item) => !activeMixIds.has(item.mixId))
+ ) {
+ return yield* Effect.fail(
</file context>
- Queue song edits so rapid changes preserve the latest draft - Ignore pending or deleted mixes in song-specific names - Report duplicate and missing mix names accurately
There was a problem hiding this comment.
2 issues found across 4 files (changes from recent commits).
Confidence score: 3/5
- In
apps/web/src/routes/shows/$showId/setlist/$songId.tsx, assigning a microphone to a mix during an optimistic create can be dropped by normalization, so the UI appears correct but the assignment never reaches persistence; this is a concrete data-loss/regression path for song edits — keep pending mix-linked microphone assignments in the normalized payload until create resolves. - In
apps/web/src/routes/shows/$showId/setlist/$songId.tsx,preserveDraftRef.currentcan stay stucktrueafter a failed save, which can block expected reset behavior on later song syncs and leave users with stale draft state; add a deterministic clear path on retry/reset (not only on successfulrun()) to prevent cross-save state leakage.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/web/src/routes/shows/$showId/setlist/$songId.tsx">
<violation number="1" location="apps/web/src/routes/shows/$showId/setlist/$songId.tsx:192">
P2: After a save fails, `preserveDraftRef.current` is set to `true` and is only ever cleared to `false` inside `run()` on a subsequent *successful* save. The reset `useEffect([song])` that re-syncs the component's internal state (name/artist/notes/assignments/mixNames/draftRef) returns early whenever `preserveDraftRef.current` is `true`. Because `SongDetail` is not keyed by `song.id` and stays mounted across song changes, if the user triggers a save that fails and then navigates to a different song via the `$songId` route, the reset effect is permanently blocked: the inputs keep showing the previous song's (failed) draft while the URL/song prop show the new song. Consider clearing `preserveDraftRef` when the `song.id` changes (or keying `SongDetail` by song id) so a failure in one song cannot freeze draft sync for a different song.</violation>
<violation number="2" location="apps/web/src/routes/shows/$showId/setlist/$songId.tsx:240">
P2: Assigning a microphone to a mix while its optimistic create is still pending appears to work in the song view, but this normalization silently removes that assignment from the song payload, so it is never persisted. The pending mix’s assignment controls could be disabled or the save could be deferred until the mix has a server ID.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| if (blockUi && isSavingRef.current) return false; | ||
| const activeMixIds = new Set(mixes.map((mix) => mix.id)); | ||
| const draft = { ...draftRef.current, ...next }; | ||
| const activeMixIds = new Set(mixes.filter((mix) => !mix.pending).map((mix) => mix.id)); |
There was a problem hiding this comment.
P2: Assigning a microphone to a mix while its optimistic create is still pending appears to work in the song view, but this normalization silently removes that assignment from the song payload, so it is never persisted. The pending mix’s assignment controls could be disabled or the save could be deferred until the mix has a server ID.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/routes/shows/$showId/setlist/$songId.tsx, line 240:
<comment>Assigning a microphone to a mix while its optimistic create is still pending appears to work in the song view, but this normalization silently removes that assignment from the song payload, so it is never persisted. The pending mix’s assignment controls could be disabled or the save could be deferred until the mix has a server ID.</comment>
<file context>
@@ -215,60 +235,76 @@ function SongDetail({
if (blockUi && isSavingRef.current) return false;
- const activeMixIds = new Set(mixes.map((mix) => mix.id));
+ const draft = { ...draftRef.current, ...next };
+ const activeMixIds = new Set(mixes.filter((mix) => !mix.pending).map((mix) => mix.id));
const activeMicrophoneIds = new Set(microphones.map((microphone) => microphone.id));
- const normalizedAssignments = (next?.assignments ?? assignments).flatMap((assignment) => {
</file context>
| const hasUnpairedMix = orderedMixes.filter((mix) => mix.id !== mainMixId).length % 2 === 1; | ||
|
|
||
| React.useEffect(() => { | ||
| if (queuedSaveCountRef.current > 0 || preserveDraftRef.current) return; |
There was a problem hiding this comment.
P2: After a save fails, preserveDraftRef.current is set to true and is only ever cleared to false inside run() on a subsequent successful save. The reset useEffect([song]) that re-syncs the component's internal state (name/artist/notes/assignments/mixNames/draftRef) returns early whenever preserveDraftRef.current is true. Because SongDetail is not keyed by song.id and stays mounted across song changes, if the user triggers a save that fails and then navigates to a different song via the $songId route, the reset effect is permanently blocked: the inputs keep showing the previous song's (failed) draft while the URL/song prop show the new song. Consider clearing preserveDraftRef when the song.id changes (or keying SongDetail by song id) so a failure in one song cannot freeze draft sync for a different song.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/routes/shows/$showId/setlist/$songId.tsx, line 192:
<comment>After a save fails, `preserveDraftRef.current` is set to `true` and is only ever cleared to `false` inside `run()` on a subsequent *successful* save. The reset `useEffect([song])` that re-syncs the component's internal state (name/artist/notes/assignments/mixNames/draftRef) returns early whenever `preserveDraftRef.current` is `true`. Because `SongDetail` is not keyed by `song.id` and stays mounted across song changes, if the user triggers a save that fails and then navigates to a different song via the `$songId` route, the reset effect is permanently blocked: the inputs keep showing the previous song's (failed) draft while the URL/song prop show the new song. Consider clearing `preserveDraftRef` when the `song.id` changes (or keying `SongDetail` by song id) so a failure in one song cannot freeze draft sync for a different song.</comment>
<file context>
@@ -178,6 +189,15 @@ function SongDetail({
const hasUnpairedMix = orderedMixes.filter((mix) => mix.id !== mainMixId).length % 2 === 1;
React.useEffect(() => {
+ if (queuedSaveCountRef.current > 0 || preserveDraftRef.current) return;
+ draftRef.current = {
+ name: song.name,
</file context>
Summary
Testing