-
Notifications
You must be signed in to change notification settings - Fork 10
fix(valuation): reuse the canonical artist for a Spotify id, never mint a second (chat#1889 row 8) #791
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(valuation): reuse the canonical artist for a Spotify id, never mint a second (chat#1889 row 8) #791
Changes from all commits
2327518
41279e3
d899d0e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| 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"; | ||
|
|
||
| vi.mock("@/lib/supabase/socials/selectSocials", () => ({ | ||
| selectSocials: vi.fn(), | ||
| })); | ||
| vi.mock("@/lib/supabase/account_socials/selectAccountSocials", () => ({ | ||
| selectAccountSocials: vi.fn(), | ||
| })); | ||
|
|
||
| const SPOTIFY_ID = "0xPoVNPnxIIUS1vrxAYV00"; | ||
|
|
||
| describe("findCanonicalArtistBySpotifyId", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| // Artists are canonical and shared (chat#1866); account_artist_ids is the | ||
| // join that lets many accounts roster one artist. So the lookup is global, | ||
| // NOT scoped to the requesting account — otherwise every account mints its | ||
| // own copy of the same Spotify artist (chat#1889 row 8). | ||
| it("finds the artist globally, not scoped to any account", async () => { | ||
| vi.mocked(selectSocials).mockResolvedValue([{ id: "social-1" }] as never); | ||
| vi.mocked(selectAccountSocials).mockResolvedValue([ | ||
| { account_id: "canonical-artist-1" }, | ||
| ] as never); | ||
|
|
||
| const found = await findCanonicalArtistBySpotifyId(SPOTIFY_ID); | ||
|
|
||
| expect(found).toBe("canonical-artist-1"); | ||
| expect(selectSocials).toHaveBeenCalledWith({ profileUrlContains: SPOTIFY_ID }); | ||
| }); | ||
|
|
||
| it("returns null when no social carries that Spotify id", async () => { | ||
| vi.mocked(selectSocials).mockResolvedValue([] as never); | ||
|
|
||
| const found = await findCanonicalArtistBySpotifyId(SPOTIFY_ID); | ||
|
|
||
| expect(found).toBeNull(); | ||
| expect(selectAccountSocials).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("returns null when the social exists but no artist is linked to it", async () => { | ||
| vi.mocked(selectSocials).mockResolvedValue([{ id: "social-1" }] as never); | ||
| vi.mocked(selectAccountSocials).mockResolvedValue([] as never); | ||
|
|
||
| expect(await findCanonicalArtistBySpotifyId(SPOTIFY_ID)).toBeNull(); | ||
| }); | ||
|
|
||
| // Never fail a valuation over a dedup lookup: falling back to creating an | ||
| // artist is strictly better than a 500. | ||
| it("returns null when the lookup throws", async () => { | ||
| vi.mocked(selectSocials).mockRejectedValue(new Error("db down")); | ||
|
|
||
| expect(await findCanonicalArtistBySpotifyId(SPOTIFY_ID)).toBeNull(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { selectSocials } from "@/lib/supabase/socials/selectSocials"; | ||
| import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials"; | ||
|
|
||
| /** | ||
| * 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). | ||
| * | ||
| * 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. | ||
| * @returns The artist's account id, or null when none exists yet. | ||
| */ | ||
| export async function findCanonicalArtistBySpotifyId( | ||
| spotifyArtistId: string, | ||
| ): Promise<string | null> { | ||
| try { | ||
| const socials = (await selectSocials({ profileUrlContains: spotifyArtistId })) ?? []; | ||
| if (socials.length === 0) return null; | ||
|
|
||
| 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; | ||
| } | ||
|
|
||
| return null; | ||
| } catch (error) { | ||
| console.error("Error resolving canonical artist by spotify id:", error); | ||
| return null; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,8 @@ | ||
| import { createArtistInDb } from "@/lib/artists/createArtistInDb"; | ||
| import { updateArtistSocials } from "@/lib/artist/updateArtistSocials"; | ||
| import { findCanonicalArtistBySpotifyId } from "@/lib/valuation/findCanonicalArtistBySpotifyId"; | ||
| import { selectAccountArtistId } from "@/lib/supabase/account_artist_ids/selectAccountArtistId"; | ||
| import { insertAccountArtistId } from "@/lib/supabase/account_artist_ids/insertAccountArtistId"; | ||
|
|
||
| /** | ||
| * Fallback roster attach for the valuation flow: when the ISRC → song_artists | ||
|
|
@@ -9,6 +12,12 @@ import { updateArtistSocials } from "@/lib/artist/updateArtistSocials"; | |
| * roster it can confirm. Server-side, account-scoped version of the marketing | ||
| * funnel's old client-side linkArtistToAccount. | ||
| * | ||
| * Reuses the canonical artist when one already carries this Spotify id, and | ||
| * only creates when none does. Artists are canonical and shared — | ||
| * `account_artist_ids` is the join that lets many accounts roster the same | ||
| * artist (chat#1866) — so creating unconditionally minted a duplicate for | ||
| * every account whose first artist was added this way (chat#1889 row 8). | ||
| * | ||
| * Link-only: the caller resolves the Spotify profile once and enriches the | ||
| * returned artist separately (runValuationHandler → enrichSearchedArtistProfile), | ||
| * so enrichment covers both the canonical and fallback paths (chat#1881 2a). | ||
|
|
@@ -29,6 +38,15 @@ export async function linkSearchedArtistToAccount(params: { | |
| try { | ||
| if (!artistName) return null; | ||
|
|
||
| const canonicalId = await findCanonicalArtistBySpotifyId(spotifyArtistId); | ||
| if (canonicalId) { | ||
| const alreadyRostered = await selectAccountArtistId(accountId, canonicalId); | ||
| if (!alreadyRostered) { | ||
| await insertAccountArtistId(accountId, canonicalId); | ||
| } | ||
| return canonicalId; | ||
| } | ||
|
Comment on lines
+41
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Repo files matching target:"
git ls-files | rg '(^|/)linkSearchedArtistToAccount\.ts$|account_artist|spotify_artist|artist' || true
echo
echo "Target file outline and relevant content:"
target="lib/valuation/linkSearchedArtistToAccount.ts"
if [ -f "$target" ]; then
wc -l "$target"
ast-grep outline "$target" || true
cat -n "$target"
fi
echo
echo "Search for helper implementations and table references:"
rg -n "findCanonicalArtistBySpotifyId|selectAccountArtistId|insertAccountArtistId|account_artist_ids|artist_ids|artist" lib -S || trueRepository: recoupable/api Length of output: 50371 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Find SQL/schema files and Supabase helpers:"
git ls-files | rg '(^|\.)sql$|schema|supabase|migration|db|data' || true
echo
echo "Supabase helper files potentially containing account_artist helpers:"
fd . lib/supabase -t f 2>/dev/null | sed -n '1,200p' || true
echo
echo "Search for account_artist_ids column/table definitions:"
rg -n "account_artist_ids|artist_ids|\"artists\"|spotify_artist|Artist" lib . 2>/dev/null \
| rg -i "id|artist|spotify|link|artist_id|account_id|unique|constraint|create table|create index|insert|select" || trueRepository: recoupable/api Length of output: 50371 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Target function:"
target="lib/valuation/linkSearchedArtistToAccount.ts"
cat -n "$target"
echo
echo "Supabase account_artist_ids helpers:"
for f in \
lib/supabase/account_artist_ids/selectAccountArtistId.ts \
lib/supabase/account_artist_ids/insertAccountArtistId.ts \
lib/supabase/account_artist_ids/getAccountArtistIds.ts \
lib/supabase/account_artist_ids/getAccountPinnedArtistIds.ts
do
echo "--- $f"
[ -f "$f" ] && sed -n '1,120p' "$f" | cat -n
done
echo
echo "Account artists table/schema definitions mentioning account_artist_ids uniqueness:"
fd -e sql . | rg -i "sql$" | xargs -r rg -n -C 3 "account_artist_ids|unique.*(account|artist)|account_id.*artist|unique.*account_artist|artist_id.*account" || true
echo
echo "Test expectations for duplicate-link behavior:"
cat -n lib/supabase/account_artist_ids/__tests__/selectAccountArtistId.test.ts
echo "---"
cat -n lib/supabase/account_artist_ids/__tests__/insertAccountArtistId.test.ts
echo "---"
cat -n lib/valuation/__tests__/linkSearchedArtistToAccount.test.ts | sed -n '70,102p'Repository: recoupable/api Length of output: 14381 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Locate SQL files and constrained indices for account_artist_ids:"
git ls-files '*.sql' | xargs -r rg -n -C 4 "create table account_artist_ids|account_artist_ids|account_artist|insert into account_artist_ids|unique (account_id, artist_id)|unique.*account_id.*artist|account_artist|on conflict|conflict" || true
echo
echo "Type definitions for account_artist_ids if present:"
git ls-files | rg "database.types|database.types.*|supabase.*types|schema" | while read -r f; do
rg -n -C 3 "account_artist_ids|accountArtistIds|artistIds|account_id\?:|artist_id\?:" "$f" || true
done
echo
echo "Behavioral model of current read-only race condition:"
python3 - <<'PY'
links = set()
# Scenario: two concurrent canonical-link requests both pass the select check
links.add(("acc", "canon"))
before_insert = not (("acc", "canon") in links)
links.add(("acc", "canon"))
after_insert = links
print("initial:", set())
print("concurrent_requests:", 2)
print("before_insert:", before_insert, "=> insertAccountArtistId called =", before_insert)
print("post_state:", sorted(after_insert))
print("duplicate_rows_possible_without_uniq_constraint:", len(after_insert) > len(set(after_insert)))
PYRepository: recoupable/api Length of output: 12003 Make canonical roster linking conflict-safe.
🤖 Prompt for AI Agents |
||
|
|
||
| const created = await createArtistInDb(artistName, accountId); | ||
| if (!created?.account_id) return null; | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: A lookup outage is treated as “no canonical artist,” so this path mints a duplicate precisely when existence cannot be verified. Preserve a distinct lookup-error result and skip fallback creation on that result while allowing valuation to continue.
Prompt for AI agents