Skip to content
Closed
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
38 changes: 38 additions & 0 deletions lib/valuation/__tests__/findCanonicalArtistBySpotifyId.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,24 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { findCanonicalArtistBySpotifyId } from "@/lib/valuation/findCanonicalArtistBySpotifyId";
import { selectSocials } from "@/lib/supabase/socials/selectSocials";
import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials";
import { selectAccountArtistIds } from "@/lib/supabase/account_artist_ids/selectAccountArtistIds";

vi.mock("@/lib/supabase/socials/selectSocials", () => ({
selectSocials: vi.fn(),
}));
vi.mock("@/lib/supabase/account_socials/selectAccountSocials", () => ({
selectAccountSocials: vi.fn(),
}));
vi.mock("@/lib/supabase/account_artist_ids/selectAccountArtistIds", () => ({
selectAccountArtistIds: vi.fn(),
}));

const SPOTIFY_ID = "0xPoVNPnxIIUS1vrxAYV00";

describe("findCanonicalArtistBySpotifyId", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(selectAccountArtistIds).mockResolvedValue([] as never);
});

// Artists are canonical and shared (chat#1866); account_artist_ids is the
Expand Down Expand Up @@ -56,4 +61,37 @@ describe("findCanonicalArtistBySpotifyId", () => {

expect(await findCanonicalArtistBySpotifyId(SPOTIFY_ID)).toBeNull();
});
// The chat add flow creates its own artist row (+ Spotify social) BEFORE the
// fire-and-forget valuation runs. If the global lookup then resolves a
// DIFFERENT canonical, insertAccountArtistId links that one too and the
// account shows two roster entries again — reproduced live on chat#1900
// after api#791 merged. An artist the account already rosters must win.
it("prefers an artist the requesting account already rosters", async () => {
vi.mocked(selectSocials).mockResolvedValue([
{ id: "social-new" },
{ id: "social-old" },
] as never);
vi.mocked(selectAccountSocials).mockImplementation((async (args: { socialId: string }) => {
if (args.socialId === "social-new") return [{ account_id: "client-created-1" }];
return [{ account_id: "old-canonical-1" }];
}) as never);
vi.mocked(selectAccountArtistIds).mockResolvedValue([
{ artist_id: "client-created-1" },
] as never);

const found = await findCanonicalArtistBySpotifyId(SPOTIFY_ID, "acct-1");

expect(found).toBe("client-created-1");
expect(selectAccountArtistIds).toHaveBeenCalledWith(["acct-1"]);
});

it("still resolves the global canonical when the account rosters nothing for the id", async () => {
vi.mocked(selectSocials).mockResolvedValue([{ id: "social-old" }] as never);
vi.mocked(selectAccountSocials).mockResolvedValue([{ account_id: "old-canonical-1" }] as never);
vi.mocked(selectAccountArtistIds).mockResolvedValue([
{ artist_id: "unrelated-artist" },
] as never);

expect(await findCanonicalArtistBySpotifyId(SPOTIFY_ID, "acct-1")).toBe("old-canonical-1");
});
});
33 changes: 25 additions & 8 deletions lib/valuation/findCanonicalArtistBySpotifyId.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,53 @@
import { selectSocials } from "@/lib/supabase/socials/selectSocials";
import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials";
import { selectAccountArtistIds } from "@/lib/supabase/account_artist_ids/selectAccountArtistIds";

/**
* The canonical artist account for a Spotify artist id, if one already exists.
*
* Artists are canonical and shared — `account_artist_ids` is the join that lets
* many accounts roster the same artist (chat#1866). So this lookup is
* deliberately **global**, not scoped to the requesting account: scoping it
* per-account is what lets every signup mint its own copy of the same Spotify
* artist, which is the duplicate `linkSearchedArtistToAccount` was producing
* (chat#1889 row 8).
* many accounts roster the same artist (chat#1866). The lookup is global, but
* **an artist the requesting account already rosters wins**: the chat add flow
* creates its own artist row (+ Spotify social) before the fire-and-forget
* valuation runs, so resolving a different canonical here would link a second
* roster entry — the exact double this exists to prevent (chat#1889 row 8,
* reproduced live on chat#1900 after api#791).
*
* Best-effort: never throws. Failing this lookup falls back to creating an
* artist, which is strictly better than failing the valuation.
*
* @param spotifyArtistId - The Spotify artist id to resolve.
* @param accountId - The requesting account; used only to prefer an
* already-rostered artist, never to scope the search.
* @returns The artist's account id, or null when none exists yet.
*/
export async function findCanonicalArtistBySpotifyId(
spotifyArtistId: string,
accountId?: string,
): Promise<string | null> {
try {
const socials = (await selectSocials({ profileUrlContains: spotifyArtistId })) ?? [];
if (socials.length === 0) return null;

const linkedArtistIds: string[] = [];
for (const social of socials) {
const links = await selectAccountSocials({ socialId: social.id });
const linked = links.find(link => link.account_id);
if (linked?.account_id) return linked.account_id;
for (const link of links) {
if (link.account_id && !linkedArtistIds.includes(link.account_id)) {
linkedArtistIds.push(link.account_id);
}
}
}
if (linkedArtistIds.length === 0) return null;

return null;
if (accountId) {
const rostered = await selectAccountArtistIds([accountId]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Valuation now loads the requesting account's entire roster just to test candidate membership, making this path grow linearly with roster size. A targeted account_artist_ids query filtered by both account and linked artist IDs would avoid repeated full-roster reads.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/valuation/findCanonicalArtistBySpotifyId.ts, line 44:

<comment>Valuation now loads the requesting account's entire roster just to test candidate membership, making this path grow linearly with roster size. A targeted `account_artist_ids` query filtered by both account and linked artist IDs would avoid repeated full-roster reads.</comment>

<file context>
@@ -1,36 +1,53 @@
 
-    return null;
+    if (accountId) {
+      const rostered = await selectAccountArtistIds([accountId]);
+      const rosteredIds = new Set(rostered.map(row => row.artist_id));
+      const alreadyRostered = linkedArtistIds.find(id => rosteredIds.has(id));
</file context>

const rosteredIds = new Set(rostered.map(row => row.artist_id));
const alreadyRostered = linkedArtistIds.find(id => rosteredIds.has(id));
if (alreadyRostered) return alreadyRostered;
}

return linkedArtistIds[0];
} catch (error) {
console.error("Error resolving canonical artist by spotify id:", error);
return null;
Expand Down
2 changes: 1 addition & 1 deletion lib/valuation/linkSearchedArtistToAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export async function linkSearchedArtistToAccount(params: {
try {
if (!artistName) return null;

const canonicalId = await findCanonicalArtistBySpotifyId(spotifyArtistId);
const canonicalId = await findCanonicalArtistBySpotifyId(spotifyArtistId, accountId);
if (canonicalId) {
const alreadyRostered = await selectAccountArtistId(accountId, canonicalId);
if (!alreadyRostered) {
Expand Down
Loading