Skip to content
Merged
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
11 changes: 11 additions & 0 deletions lib/supabase/socials/selectSocials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import supabase from "../serverClient";
type SelectSocialsParams = {
id?: string;
profile_url?: string;
/**
* Substring match (ilike) on profile_url. Use for Spotify-id lookups:
* profile_url is stored inconsistently — with and without a scheme,
* sometimes with a ?si= query — so the exact-match `profile_url` param
* misses real matches (chat#1889 row 8).
*/
profileUrlContains?: string;
};

/**
Expand All @@ -26,6 +33,10 @@ export async function selectSocials(
query = query.eq("profile_url", params.profile_url);
}

if (params.profileUrlContains) {
query = query.ilike("profile_url", `%${params.profileUrlContains}%`);
}

const { data, error } = await query;

if (error) {
Expand Down
59 changes: 59 additions & 0 deletions lib/valuation/__tests__/findCanonicalArtistBySpotifyId.test.ts
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();
});
});
37 changes: 37 additions & 0 deletions lib/valuation/__tests__/linkSearchedArtistToAccount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { linkSearchedArtistToAccount } from "../linkSearchedArtistToAccount";
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";

vi.mock("@/lib/artists/createArtistInDb", () => ({ createArtistInDb: vi.fn() }));
vi.mock("@/lib/artist/updateArtistSocials", () => ({ updateArtistSocials: vi.fn() }));
vi.mock("@/lib/valuation/findCanonicalArtistBySpotifyId", () => ({
findCanonicalArtistBySpotifyId: vi.fn(),
}));
vi.mock("@/lib/supabase/account_artist_ids/selectAccountArtistId", () => ({
selectAccountArtistId: vi.fn(),
}));
vi.mock("@/lib/supabase/account_artist_ids/insertAccountArtistId", () => ({
insertAccountArtistId: vi.fn(),
}));

const accountId = "550e8400-e29b-41d4-a716-446655440000";
const spotifyArtistId = "4q3ewBCX7sLwd24euuV69X";
Expand All @@ -17,6 +29,9 @@ describe("linkSearchedArtistToAccount", () => {
vi.clearAllMocks();
vi.mocked(createArtistInDb).mockResolvedValue({ account_id: createdArtistId } as never);
vi.mocked(updateArtistSocials).mockResolvedValue([] as never);
vi.mocked(findCanonicalArtistBySpotifyId).mockResolvedValue(null);
vi.mocked(selectAccountArtistId).mockResolvedValue(null as never);
vi.mocked(insertAccountArtistId).mockResolvedValue(undefined as never);
});

it("creates the searched artist, attaches its Spotify social, and returns the new id", async () => {
Expand Down Expand Up @@ -57,4 +72,26 @@ describe("linkSearchedArtistToAccount", () => {
linkSearchedArtistToAccount({ accountId, spotifyArtistId, artistName }),
).resolves.toBeNull();
});
// Artists are canonical and shared (chat#1866). Creating a second one for a
// Spotify id that already exists is what duplicated every cold-start signup's
// roster (chat#1889 row 8).
it("rosters the existing canonical artist instead of creating a duplicate", async () => {
vi.mocked(findCanonicalArtistBySpotifyId).mockResolvedValue("canonical-1");

const result = await linkSearchedArtistToAccount({ accountId, spotifyArtistId, artistName });

expect(createArtistInDb).not.toHaveBeenCalled();
expect(insertAccountArtistId).toHaveBeenCalledWith(accountId, "canonical-1");
expect(result).toBe("canonical-1");
});

it("does not re-link a canonical artist the account already rosters", async () => {
vi.mocked(findCanonicalArtistBySpotifyId).mockResolvedValue("canonical-1");
vi.mocked(selectAccountArtistId).mockResolvedValue({ id: "link-1" } as never);

const result = await linkSearchedArtistToAccount({ accountId, spotifyArtistId, artistName });

expect(insertAccountArtistId).not.toHaveBeenCalled();
expect(result).toBe("canonical-1");
});
});
38 changes: 38 additions & 0 deletions lib/valuation/findCanonicalArtistBySpotifyId.ts
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;

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: 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
Check if this issue is valid — if so, understand the root cause and fix it. At lib/valuation/findCanonicalArtistBySpotifyId.ts, line 33:

<comment>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.</comment>

<file context>
@@ -0,0 +1,38 @@
+      if (linked?.account_id) return linked.account_id;
+    }
+
+    return null;
+  } catch (error) {
+    console.error("Error resolving canonical artist by spotify id:", error);
</file context>

} catch (error) {
console.error("Error resolving canonical artist by spotify id:", error);
return null;
}
}
18 changes: 18 additions & 0 deletions lib/valuation/linkSearchedArtistToAccount.ts
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
Expand All @@ -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).
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 || true

Repository: 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" || true

Repository: 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)))
PY

Repository: recoupable/api

Length of output: 12003


Make canonical roster linking conflict-safe.

selectAccountArtistIdinsertAccountArtistId is the same check-then-insert race as the canonical catalog attach path. Concurrent setups can both observe no row and insert separately without an account/artist uniqueness guard, or one insert can error and turn a successfully rostered artist into null. Enforce this pair as unique and replace this read-check with a conflict-safe insert/upsert that treats an existing row as success.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/valuation/linkSearchedArtistToAccount.ts` around lines 41 - 48, The
canonical roster linking flow in linkSearchedArtistToAccount must replace the
selectAccountArtistId-then-insertAccountArtistId race with a conflict-safe
insert/upsert. Enforce the account/artist pair uniqueness constraint used by
this path, treat conflicts from concurrent inserts as successful linking, and
preserve returning canonicalId rather than converting a successful roster link
into null.


const created = await createArtistInDb(artistName, accountId);
if (!created?.account_id) return null;

Expand Down
Loading