Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/web/src/components/shows/ShowLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type { ChatChannelId, ShowId, SongId } from "@showtime/contracts";
import { useAtomValue } from "@effect/atom-react";
import { songAtoms } from "@/client";
import { useCreateSong } from "@/components/songs/useCreateSong";
import { createdSongHandoff } from "@/components/songs/CreatedSongHandoff";
import { ShowPageAction } from "./ShowPageAction";
import { ProfileSwitcher } from "@/components/profiles/ProfileSwitcher";
import { ChatDrawer, ChatUnreadBadge } from "@/components/chats/ChatDrawer";
Expand All @@ -53,13 +54,19 @@ export function ShowLayout() {
const params = useParams({ strict: false });
const currentSongId = typeof params.songId === "string" ? (params.songId as SongId) : undefined;
const songsResult = useAtomValue(songAtoms(typedShowId).songs);
const syncedSongsResult = useAtomValue(songAtoms(typedShowId).syncedSongs);
const pathname = useRouterState({ select: (state) => state.location.pathname });
const isAllSongsRoute = /\/setlist\/?$/.test(pathname);
const songs = AsyncResult.isSuccess(songsResult)
? songsResult.value
: AsyncResult.isFailure(songsResult)
? (Option.getOrUndefined(songsResult.previousSuccess)?.value ?? [])
: [];
React.useEffect(() => {
if (AsyncResult.isSuccess(syncedSongsResult)) {
createdSongHandoff.reconcile(typedShowId, syncedSongsResult.value, syncedSongsResult);
}
}, [syncedSongsResult, typedShowId]);
const songCreator = useCreateSong(typedShowId, currentSongId);
return (
<React.Fragment>
Expand Down
65 changes: 65 additions & 0 deletions apps/web/src/components/songs/CreatedSongHandoff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vite-plus/test";
import { Schema } from "effect";
import { Song, SongName, type ShowId, type SongId } from "@showtime/contracts";
import { makeCreatedSongHandoff } from "./CreatedSongHandoff";

const showId = "show_0123456789abcdef" as ShowId;
const song = Schema.decodeUnknownSync(Song)({
id: "song_0123456789abcdef" as SongId,
name: "",
artist: "",
mixAssignments: [],
createdAt: "2026-07-24T12:00:00.000Z",
updatedAt: "2026-07-24T12:00:00.000Z",
});

describe("CreatedSongHandoff", () => {
it("retains a confirmed creation until the streamed snapshot observes it", () => {
const handoff = makeCreatedSongHandoff();
const baselineSnapshot = {};

handoff.remember(showId, song, undefined, baselineSnapshot);
expect(handoff.find(showId, song.id)).toBe(song);

handoff.reconcile(showId, [], baselineSnapshot);
expect(handoff.find(showId, song.id)).toBe(song);

handoff.reconcile(showId, [{ ...song, pending: true }], {});
expect(handoff.find(showId, song.id)).toBe(song);

handoff.reconcile(showId, [song], {});
expect(handoff.find(showId, song.id)).toBeUndefined();
});

it("can forget a creation after it is deleted before reconciliation", () => {
const handoff = makeCreatedSongHandoff();

handoff.remember(showId, song, undefined, {});
handoff.forget(showId, song.id);

expect(handoff.find(showId, song.id)).toBeUndefined();
});

it("preserves the intended insertion position while the snapshot is delayed", () => {
const handoff = makeCreatedSongHandoff();
const anchor = {
...song,
id: "song_fedcba9876543210" as SongId,
name: SongName.make("Anchor"),
};

handoff.remember(showId, song, anchor.id, {});

expect(handoff.provisionalNumber(showId, song.id, [anchor])).toBe(2);
});

it("expires a confirmed creation when a newer authoritative snapshot omits it", () => {
const handoff = makeCreatedSongHandoff();
const baselineSnapshot = {};

handoff.remember(showId, song, undefined, baselineSnapshot);
handoff.reconcile(showId, [], {});

expect(handoff.find(showId, song.id)).toBeUndefined();
});
});
63 changes: 63 additions & 0 deletions apps/web/src/components/songs/CreatedSongHandoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { ShowId, Song, SongId } from "@showtime/contracts";

const keyFor = (showId: ShowId, songId: SongId) => `${showId}:${songId}`;

export const makeCreatedSongHandoff = () => {
const confirmed = new Map<
string,
{
readonly song: Song;
readonly insertAfterSongId?: SongId;
readonly baselineSnapshot: object;
}
>();

const remember = (
showId: ShowId,
song: Song,
insertAfterSongId: SongId | undefined,
baselineSnapshot: object,
) => {
confirmed.set(keyFor(showId, song.id), { song, insertAfterSongId, baselineSnapshot });
};

const find = (showId: ShowId, songId: SongId) => confirmed.get(keyFor(showId, songId))?.song;

const provisionalNumber = (showId: ShowId, songId: SongId, syncedSongs: ReadonlyArray<Song>) => {
const entry = confirmed.get(keyFor(showId, songId));
if (!entry?.insertAfterSongId) return syncedSongs.length + 1;
const anchorIndex = syncedSongs.findIndex((song) => song.id === entry.insertAfterSongId);
return anchorIndex < 0 ? syncedSongs.length + 1 : anchorIndex + 2;
};

const forget = (showId: ShowId, songId: SongId) => {
confirmed.delete(keyFor(showId, songId));
};

const reconcile = (
showId: ShowId,
songs: ReadonlyArray<Song & { readonly pending?: boolean }>,
snapshot: object,
) => {
if (songs.some((song) => song.pending)) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Pending optimistic snapshots are not retained here: reconcile receives raw syncedSongs, while pending exists only on the separate optimistic songs atom. This guard never returns in production, so route handoff can still be discarded on the next differing raw snapshot; pass optimistic pending state into reconciliation (or remove this ineffective guard and its misleading test).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/components/songs/CreatedSongHandoff.ts, line 42:

<comment>Pending optimistic snapshots are not retained here: `reconcile` receives raw `syncedSongs`, while `pending` exists only on the separate optimistic `songs` atom. This guard never returns in production, so route handoff can still be discarded on the next differing raw snapshot; pass optimistic pending state into reconciliation (or remove this ineffective guard and its misleading test).</comment>

<file context>
@@ -25,9 +37,18 @@ export const makeCreatedSongHandoff = () => {
   ) => {
-    for (const song of songs) {
-      if (!song.pending) forget(showId, song.id);
+    if (songs.some((song) => song.pending)) return;
+
+    const syncedIds = new Set(songs.map((song) => song.id));
</file context>


const syncedIds = new Set(songs.map((song) => song.id));
for (const [key, entry] of confirmed) {
if (
key.startsWith(`${showId}:`) &&
(syncedIds.has(entry.song.id) || snapshot !== entry.baselineSnapshot)
) {
confirmed.delete(key);
}
}
};

return { remember, find, forget, provisionalNumber, reconcile } as const;
};

/**
* Bridges the short interval between a successful create response and the next
* full songs snapshot. Entries are discarded as soon as that snapshot observes
* them, so later deletions cannot be hidden by stale client state.
*/
export const createdSongHandoff = makeCreatedSongHandoff();
29 changes: 23 additions & 6 deletions apps/web/src/components/songs/useCreateSong.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,35 @@
import * as React from "react";
import { Exit } from "effect";
import { useNavigate } from "@tanstack/react-router";
import { type ShowId, type SongArtist, type SongId, type SongName } from "@showtime/contracts";
import { useAtomSet } from "@effect/atom-react";
import {
makeClientId,
songIdPrefix,
type ShowId,
type SongArtist,
type SongId,
type SongName,
} from "@showtime/contracts";
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { rpcErrorMessageFromCause, songAtoms, songsRpcReactivityKey } from "@/client";
import { createdSongHandoff } from "./CreatedSongHandoff";

export function useCreateSong(showId: ShowId, insertAfterSongId?: SongId) {
const navigate = useNavigate();
const songsSnapshot = useAtomValue(songAtoms(showId).syncedSongs);
const create = useAtomSet(songAtoms(showId).create, { mode: "promiseExit" });
const [isCreating, setIsCreating] = React.useState(false);
const [error, setError] = React.useState<string>();

const createSong = async () => {
if (isCreating) return;
const baselineSnapshot = songsSnapshot;
setIsCreating(true);
setError(undefined);
const id = makeClientId(songIdPrefix) as SongId;
const result = await create({
payload: {
showId,
id,
name: "" as SongName,
artist: "" as SongArtist,
insertAfterSongId,
Expand All @@ -29,10 +41,15 @@ export function useCreateSong(showId: ShowId, insertAfterSongId?: SongId) {
setIsCreating(false);
return;
}
await navigate({
to: "/shows/$showId/setlist/$songId",
params: { showId, songId: result.value.id },
});
createdSongHandoff.remember(showId, result.value, insertAfterSongId, baselineSnapshot);
try {
await navigate({
to: "/shows/$showId/setlist/$songId",
params: { showId, songId: result.value.id },
});
} catch {
setError("The song was added, but its page could not be opened. Open it from the setlist.");
}
setIsCreating(false);
};

Expand Down
42 changes: 33 additions & 9 deletions apps/web/src/routes/shows/$showId/setlist/$songId.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AsyncResult } from "effect/unstable/reactivity";
import { Exit } from "effect";
import { Exit, Option } from "effect";
import { AlertCircleIcon, EllipsisIcon, MusicIcon, Trash2Icon } from "lucide-react";
import {
mainMixId,
Expand Down Expand Up @@ -51,7 +51,8 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { mixAtoms } from "@/client";
import { microphoneAtoms } from "@/client";
import { songAtoms, songsRpcReactivityKey } from "@/client";
import { rpcErrorMessageFromCause } from "@/client";
import { asyncResultValueOrElse, rpcErrorMessageFromCause } from "@/client";
import { createdSongHandoff } from "@/components/songs/CreatedSongHandoff";

export const Route = createFileRoute("/shows/$showId/setlist/$songId")({
component: RouteComponent,
Expand All @@ -63,13 +64,19 @@ function RouteComponent() {
const songsResult = useAtomValue(songAtoms(typedShowId).songs);
const mixesResult = useAtomValue(mixAtoms(typedShowId).mixes);
const microphonesResult = useAtomValue(microphoneAtoms(typedShowId).microphones);
const songs = AsyncResult.getOrElse(songsResult, () => []);
const songs = asyncResultValueOrElse(songsResult, () => []);
const songIndex = songs.findIndex((song) => song.id === songId);
const song = songs[songIndex];
const mixes = AsyncResult.isSuccess(mixesResult) ? mixesResult.value : [];
const microphones = AsyncResult.isSuccess(microphonesResult) ? microphonesResult.value : [];
const song = songs[songIndex] ?? createdSongHandoff.find(typedShowId, songId as SongId);
const songNumber =
songIndex >= 0
? songIndex + 1
: createdSongHandoff.provisionalNumber(typedShowId, songId as SongId, songs);
const mixes = asyncResultValueOrElse(mixesResult, () => []);
const microphones = asyncResultValueOrElse(microphonesResult, () => []);
const hasMixes = Option.isSome(AsyncResult.value(mixesResult));
const hasMicrophones = Option.isSome(AsyncResult.value(microphonesResult));

if (AsyncResult.isInitial(songsResult)) {
if (AsyncResult.isInitial(songsResult) && !song) {
return (
<Empty>
<EmptyHeader>
Expand All @@ -81,7 +88,7 @@ function RouteComponent() {
</Empty>
);
}
if (AsyncResult.isFailure(songsResult) && songs.length === 0) {
if (AsyncResult.isFailure(songsResult) && songs.length === 0 && !song) {
return (
<Empty>
<EmptyHeader>
Expand All @@ -107,12 +114,28 @@ function RouteComponent() {
</Empty>
);
}
if (!hasMixes || !hasMicrophones) {
const failed =
(AsyncResult.isFailure(mixesResult) && !hasMixes) ||
(AsyncResult.isFailure(microphonesResult) && !hasMicrophones);
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">{failed ? <AlertCircleIcon /> : <Spinner />}</EmptyMedia>
<EmptyTitle>
{failed ? "Song details could not be loaded" : "Loading song details"}
</EmptyTitle>
{failed && <EmptyDescription>Check your connection and try again.</EmptyDescription>}
</EmptyHeader>
</Empty>
);
}

return (
<SongDetail
showId={typedShowId}
song={song}
number={songIndex + 1}
number={songNumber}
mixes={mixes}
microphones={microphones}
/>
Expand Down Expand Up @@ -548,6 +571,7 @@ function SongDeleteDialog({
setIsDeleting(false);
return;
}
createdSongHandoff.forget(showId, songId);
onOpenChange(false);
void navigate({ to: "/shows/$showId/setlist", params: { showId } });
};
Expand Down
4 changes: 2 additions & 2 deletions packages/backend/src/rpc/Rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,10 @@ const handlers = ShowtimeRpcs.toLayer(
"mixes.delete": ({ showId, id }) =>
sync.mutation(mixesSyncKey(showId), mixes.delete({ showId, id })),
"songs.list": ({ showId }) => sync.query(songsSyncKey(showId), songs.list(showId)),
"songs.create": ({ showId, name, artist, insertAfterSongId }) =>
"songs.create": ({ showId, id, name, artist, insertAfterSongId }) =>
sync.mutation(
songsSyncKey(showId),
songs.create({ showId, name, artist, insertAfterSongId }),
songs.create({ showId, id, name, artist, insertAfterSongId }),
),
"songs.edit": ({ showId, id, name, artist, notes, mixAssignments, microphoneNames }) =>
sync.mutation(
Expand Down
21 changes: 20 additions & 1 deletion packages/backend/src/songs/SongService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vite-plus/test";
import type { SongArtist, SongName } from "@showtime/contracts";
import type { SongArtist, SongId, SongName } from "@showtime/contracts";
import * as Ids from "../ids/Ids.js";
import * as ShowDiscovery from "../shows/ShowDiscovery.js";
import * as ShowFile from "../shows/ShowFile.js";
Expand Down Expand Up @@ -130,6 +130,25 @@ describe("SongService", () => {
expect(result.map((song) => song.name)).toEqual(["First", "Inserted", "Second"]);
});

it("returns the existing song when a client-generated create request is retried", async () => {
const home = await mkdtemp(path.join(os.tmpdir(), "showtime-home-"));
tempHomes.add(home);
const id = "song_0123456789abcdef" as SongId;
const result = await Effect.runPromise(
Effect.gen(function* () {
const shows = yield* ShowService;
const songs = yield* SongService;
const show = yield* shows.create({ name: "Festival", color: "sky" });
const first = yield* songs.create({ showId: show.id, id, ...songInput("First") });
const retry = yield* songs.create({ showId: show.id, id, ...songInput("First") });
return { first, retry, listed: yield* songs.list(show.id) };
}).pipe(Effect.provide(makeLayer(home))),
);

expect(result.retry).toEqual(result.first);
expect(result.listed).toEqual([result.first]);
});

it("stores a song-specific microphone name and removes it when it matches the inherited name", async () => {
const home = await mkdtemp(path.join(os.tmpdir(), "showtime-home-"));
tempHomes.add(home);
Expand Down
13 changes: 10 additions & 3 deletions packages/backend/src/songs/SongService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface SongServiceShape {
readonly list: (showId: ShowId) => Effect.Effect<ReadonlyArray<Song>, RpcError>;
readonly create: (params: {
readonly showId: ShowId;
readonly id?: SongId;
readonly name: SongName;
readonly artist: SongArtist;
readonly insertAfterSongId?: SongId;
Expand Down Expand Up @@ -59,7 +60,7 @@ const make = Effect.fnUntraced(function* () {
});

const create: SongServiceShape["create"] = Effect.fnUntraced(function* (params) {
const id = yield* ids.makeSongId;
const id = params.id ?? (yield* ids.makeSongId);
const name = yield* decodeSongName(params.name.trim()).pipe(
Effect.mapError(toRpcError("Invalid song name.")),
);
Expand All @@ -75,8 +76,14 @@ const make = Effect.fnUntraced(function* () {
createdAt: now,
updatedAt: now,
};
yield* repository
const { document: updated } = yield* repository
.update(params.showId, (document) => {
// The client owns the ID for new create requests. If a response is lost
// and the RPC is retried after persistence, return the original song
// instead of inserting a duplicate.
if (document.songs.some((item) => item.id === id && item.deletedAt === undefined)) {
return document;
}
if (
params.insertAfterSongId !== undefined &&
!document.songs.some(
Expand All @@ -90,7 +97,7 @@ const make = Effect.fnUntraced(function* () {
return { ...document, songs: songs.value };
})
.pipe(Effect.mapError(toRpcError("Could not add song.")));
return song;
return updated.songs.find((item) => item.id === id && item.deletedAt === undefined)!;
});

const edit: SongServiceShape["edit"] = Effect.fnUntraced(function* (params) {
Expand Down
Loading