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
65 changes: 45 additions & 20 deletions lib/artists/__tests__/createArtistPostHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { NextRequest, NextResponse } from "next/server";

import { createArtistPostHandler } from "../createArtistPostHandler";

const mockCreateArtistInDb = vi.fn();
const mockResolveOrCreateArtist = vi.fn();
const mockValidateAuthContext = vi.fn();

vi.mock("@/lib/artists/createArtistInDb", () => ({
createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args),
vi.mock("@/lib/artists/resolveOrCreateArtist", () => ({
resolveOrCreateArtist: (...args: unknown[]) => mockResolveOrCreateArtist(...args),
}));

vi.mock("@/lib/auth/validateAuthContext", () => ({
Expand Down Expand Up @@ -45,18 +45,16 @@ describe("createArtistPostHandler", () => {
account_info: [{ image: null }],
account_socials: [],
};
mockCreateArtistInDb.mockResolvedValue(mockArtist);
mockResolveOrCreateArtist.mockResolvedValue({ artist: mockArtist, created: true });

const request = createRequest({ name: "Test Artist" });
const response = await createArtistPostHandler(request);
const data = await response.json();

expect(response.status).toBe(201);
expect(data.artist).toEqual(mockArtist);
expect(mockCreateArtistInDb).toHaveBeenCalledWith(
"Test Artist",
"api-key-account-id",
undefined,
expect(mockResolveOrCreateArtist).toHaveBeenCalledWith(
expect.objectContaining({ name: "Test Artist", accountId: "api-key-account-id" }),
);
Comment on lines +56 to 58

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Test 'passes organization_id to createArtistInDb' title now references a function that is no longer the public API, and the assertion no longer verifies organizationId was forwarded. The old test explicitly checked the org UUID in the third arg; the new assertion only checks name and accountId, so it would pass even if organizationId were dropped. Either add organizationId to the objectContaining matcher or rename the test to reflect what it actually validates.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/artists/__tests__/createArtistPostHandler.test.ts, line 56:

<comment>Test 'passes organization_id to createArtistInDb' title now references a function that is no longer the public API, and the assertion no longer verifies organizationId was forwarded. The old test explicitly checked the org UUID in the third arg; the new assertion only checks name and accountId, so it would pass even if organizationId were dropped. Either add organizationId to the objectContaining matcher or rename the test to reflect what it actually validates.</comment>

<file context>
@@ -45,18 +45,16 @@ describe("createArtistPostHandler", () => {
-      "Test Artist",
-      "api-key-account-id",
-      undefined,
+    expect(mockResolveOrCreateArtist).toHaveBeenCalledWith(
+      expect.objectContaining({ name: "Test Artist", accountId: "api-key-account-id" }),
     );
</file context>
Suggested change
expect(mockResolveOrCreateArtist).toHaveBeenCalledWith(
expect.objectContaining({ name: "Test Artist", accountId: "api-key-account-id" }),
);
expect(mockResolveOrCreateArtist).toHaveBeenCalledWith(
expect.objectContaining({ name: "Test Artist", accountId: "api-key-account-id", organizationId: "660e8400-e29b-41d4-a716-446655440001" }),
);

});

Expand All @@ -74,18 +72,19 @@ describe("createArtistPostHandler", () => {
account_info: [{ image: null }],
account_socials: [],
};
mockCreateArtistInDb.mockResolvedValue(mockArtist);
mockResolveOrCreateArtist.mockResolvedValue({ artist: mockArtist, created: true });

const request = createRequest({
name: "Test Artist",
account_id: "550e8400-e29b-41d4-a716-446655440000",
});
const response = await createArtistPostHandler(request);

expect(mockCreateArtistInDb).toHaveBeenCalledWith(
"Test Artist",
"550e8400-e29b-41d4-a716-446655440000",
undefined,
expect(mockResolveOrCreateArtist).toHaveBeenCalledWith(
expect.objectContaining({
name: "Test Artist",
accountId: "550e8400-e29b-41d4-a716-446655440000",
}),
);
expect(response.status).toBe(201);
});
Expand Down Expand Up @@ -115,7 +114,7 @@ describe("createArtistPostHandler", () => {
account_info: [{ image: null }],
account_socials: [],
};
mockCreateArtistInDb.mockResolvedValue(mockArtist);
mockResolveOrCreateArtist.mockResolvedValue({ artist: mockArtist, created: true });

const request = createRequest({
name: "Test Artist",
Expand All @@ -124,10 +123,8 @@ describe("createArtistPostHandler", () => {

await createArtistPostHandler(request);

expect(mockCreateArtistInDb).toHaveBeenCalledWith(
"Test Artist",
"api-key-account-id",
"660e8400-e29b-41d4-a716-446655440001",
expect(mockResolveOrCreateArtist).toHaveBeenCalledWith(
expect.objectContaining({ name: "Test Artist", accountId: "api-key-account-id" }),
);
});

Expand Down Expand Up @@ -178,7 +175,7 @@ describe("createArtistPostHandler", () => {
});

it("returns 500 when artist creation fails", async () => {
mockCreateArtistInDb.mockResolvedValue(null);
mockResolveOrCreateArtist.mockResolvedValue({ artist: null, created: true });

const request = createRequest({ name: "Test Artist" });
const response = await createArtistPostHandler(request);
Expand All @@ -189,7 +186,7 @@ describe("createArtistPostHandler", () => {
});

it("returns 500 with error message when exception thrown", async () => {
mockCreateArtistInDb.mockRejectedValue(new Error("Database error"));
mockResolveOrCreateArtist.mockRejectedValue(new Error("Database error"));

const request = createRequest({ name: "Test Artist" });
const response = await createArtistPostHandler(request);
Expand All @@ -198,4 +195,32 @@ describe("createArtistPostHandler", () => {
expect(response.status).toBe(500);
expect(data.error).toBe("Database error");
});
// Row 8 (chat#1889): 200 = existing canonical linked, 201 = created.
it("returns 200 when an existing canonical was linked instead of created", async () => {
mockResolveOrCreateArtist.mockResolvedValue({
artist: { id: "canonical-1", account_id: "canonical-1", name: "Del Water Gap" },
created: false,
});
const response = await createArtistPostHandler(
createRequest({ name: "Del Water Gap", spotify_artist_id: "0xPoVNPnxIIUS1vrxAYV00" }),
);

expect(response.status).toBe(200);
const json = await response.json();
expect(json.artist.account_id).toBe("canonical-1");
});

it("passes the spotify id through to resolveOrCreateArtist", async () => {
mockResolveOrCreateArtist.mockResolvedValue({
artist: { id: "new-1", account_id: "new-1", name: "X" },
created: true,
});
await createArtistPostHandler(
createRequest({ name: "X", spotify_artist_id: "0xPoVNPnxIIUS1vrxAYV00" }),
);

expect(mockResolveOrCreateArtist).toHaveBeenCalledWith(
expect.objectContaining({ spotifyArtistId: "0xPoVNPnxIIUS1vrxAYV00" }),
);
});
});
107 changes: 107 additions & 0 deletions lib/artists/__tests__/resolveOrCreateArtist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { resolveOrCreateArtist } from "@/lib/artists/resolveOrCreateArtist";
import { createArtistInDb } from "@/lib/artists/createArtistInDb";
import { findCanonicalArtistBySpotifyId } from "@/lib/valuation/findCanonicalArtistBySpotifyId";
import { selectAccountArtistId } from "@/lib/supabase/account_artist_ids/selectAccountArtistId";
import { insertAccountArtistId } from "@/lib/supabase/account_artist_ids/insertAccountArtistId";
import { selectAccountWithSocials } from "@/lib/supabase/accounts/selectAccountWithSocials";
import { updateArtistSocials } from "@/lib/artist/updateArtistSocials";

vi.mock("@/lib/artists/createArtistInDb", () => ({ createArtistInDb: 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(),
}));
vi.mock("@/lib/supabase/accounts/selectAccountWithSocials", () => ({
selectAccountWithSocials: vi.fn(),
}));
vi.mock("@/lib/artist/updateArtistSocials", () => ({ updateArtistSocials: vi.fn() }));

const SPOTIFY_ID = "0xPoVNPnxIIUS1vrxAYV00";
const created = { id: "new-1", account_id: "new-1", name: "Del Water Gap" };

describe("resolveOrCreateArtist", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(createArtistInDb).mockResolvedValue(created as never);
vi.mocked(findCanonicalArtistBySpotifyId).mockResolvedValue(null);
vi.mocked(selectAccountArtistId).mockResolvedValue(null as never);
vi.mocked(selectAccountWithSocials).mockResolvedValue({
id: "canonical-1",
name: "Del Water Gap",
} as never);
});

it("creates and attaches the Spotify social when no canonical exists", async () => {
const result = await resolveOrCreateArtist({
name: "Del Water Gap",
accountId: "acct-1",
spotifyArtistId: SPOTIFY_ID,
});

expect(createArtistInDb).toHaveBeenCalledWith("Del Water Gap", "acct-1", undefined);
expect(updateArtistSocials).toHaveBeenCalledWith("new-1", {
SPOTIFY: `https://open.spotify.com/artist/${SPOTIFY_ID}`,
});
expect(result).toEqual({ artist: created, created: true });

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: Test "returns the created artist when the social attach fails" verifies resilience to a rejected updateArtistSocials but doesn't assert the social attach was attempted. If a regression causes the function to silently skip updateArtistSocials (no call), the mock rejection never fires and the test passes trivially because it only checks the return value. Add expect(updateArtistSocials).toHaveBeenCalledWith("new-1", { SPOTIFY: \https://open.spotify.com/artist/${SPOTIFY_ID}\` });` to confirm the action was attempted before the catch.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/artists/__tests__/resolveOrCreateArtist.test.ts, line 51:

<comment>Test "returns the created artist when the social attach fails" verifies resilience to a rejected updateArtistSocials but doesn't assert the social attach was attempted. If a regression causes the function to silently skip updateArtistSocials (no call), the mock rejection never fires and the test passes trivially because it only checks the return value. Add `expect(updateArtistSocials).toHaveBeenCalledWith("new-1", { SPOTIFY: \`https://open.spotify.com/artist/${SPOTIFY_ID}\` });` to confirm the action was attempted before the catch.</comment>

<file context>
@@ -0,0 +1,107 @@
+    expect(updateArtistSocials).toHaveBeenCalledWith("new-1", {
+      SPOTIFY: `https://open.spotify.com/artist/${SPOTIFY_ID}`,
+    });
+    expect(result).toEqual({ artist: created, created: true });
+  });
+
</file context>

});

// One canonical artist per Spotify id (chat#1889, decision 2026-07-29):
// when it exists, link it to the account — never mint a second row.
it("links and returns the existing canonical instead of creating", async () => {
vi.mocked(findCanonicalArtistBySpotifyId).mockResolvedValue("canonical-1");

const result = await resolveOrCreateArtist({
name: "Del Water Gap",
accountId: "acct-1",
spotifyArtistId: SPOTIFY_ID,
});

expect(createArtistInDb).not.toHaveBeenCalled();
expect(updateArtistSocials).not.toHaveBeenCalled();
expect(insertAccountArtistId).toHaveBeenCalledWith("acct-1", "canonical-1");
expect(result.created).toBe(false);
expect(result.artist).toMatchObject({ id: "canonical-1", account_id: "canonical-1" });
});

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

const result = await resolveOrCreateArtist({
name: "Del Water Gap",
accountId: "acct-1",
spotifyArtistId: SPOTIFY_ID,
});

expect(insertAccountArtistId).not.toHaveBeenCalled();
expect(result.created).toBe(false);
});

it("plain create path is untouched when no spotify id is given", async () => {
const result = await resolveOrCreateArtist({ name: "X", accountId: "acct-1" });

expect(findCanonicalArtistBySpotifyId).not.toHaveBeenCalled();
expect(updateArtistSocials).not.toHaveBeenCalled();
expect(result).toEqual({ artist: created, created: true });
});

// Enrichment is non-fatal (same contract as chat#1892): the row exists by
// the time the social attach runs, so a failed attach must not fail the add.
it("returns the created artist when the social attach fails", async () => {
vi.mocked(updateArtistSocials).mockRejectedValue(new Error("nope"));

const result = await resolveOrCreateArtist({
name: "Del Water Gap",
accountId: "acct-1",
spotifyArtistId: SPOTIFY_ID,
});

expect(result).toEqual({ artist: created, created: true });
});
});
22 changes: 22 additions & 0 deletions lib/artists/__tests__/validateCreateArtistBody.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,4 +253,26 @@ describe("validateCreateArtistBody", () => {
});
});
});
// Row 8 (chat#1889): spotify_artist_id rides the create so the server can
// resolve-or-create the canonical instead of minting a duplicate per signup.
it("passes spotify_artist_id through when provided", async () => {
const request = createRequest(
{ name: "Del Water Gap", spotify_artist_id: "0xPoVNPnxIIUS1vrxAYV00" },
{ "x-api-key": "k" },
);
const result = await validateCreateArtistBody(request);

expect(result).not.toBeInstanceOf(NextResponse);
if (!(result instanceof NextResponse)) {
expect(result.spotifyArtistId).toBe("0xPoVNPnxIIUS1vrxAYV00");
}
});

it("rejects an empty spotify_artist_id", async () => {
const request = createRequest({ name: "X", spotify_artist_id: "" }, { "x-api-key": "k" });
const result = await validateCreateArtistBody(request);

expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) expect(result.status).toBe(400);
});
});
19 changes: 12 additions & 7 deletions lib/artists/createArtistPostHandler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateCreateArtistBody } from "@/lib/artists/validateCreateArtistBody";
import { createArtistInDb } from "@/lib/artists/createArtistInDb";
import { resolveOrCreateArtist } from "@/lib/artists/resolveOrCreateArtist";

/**
* Handler for POST /api/artists.
Expand All @@ -26,11 +26,12 @@ export async function createArtistPostHandler(request: NextRequest): Promise<Nex
}

try {
const artist = await createArtistInDb(
validated.name,
validated.accountId,
validated.organizationId,
);
const { artist, created } = await resolveOrCreateArtist({

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 failed canonical-to-account link can now return raw database error text to API clients through this new resolver call. The 500 path should log the exception server-side and return a fixed generic message such as Internal server error.

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

<comment>A failed canonical-to-account link can now return raw database error text to API clients through this new resolver call. The 500 path should log the exception server-side and return a fixed generic message such as `Internal server error`.</comment>

<file context>
@@ -26,11 +26,12 @@ export async function createArtistPostHandler(request: NextRequest): Promise<Nex
-      validated.accountId,
-      validated.organizationId,
-    );
+    const { artist, created } = await resolveOrCreateArtist({
+      name: validated.name,
+      accountId: validated.accountId,
</file context>

name: validated.name,
accountId: validated.accountId,
organizationId: validated.organizationId,
spotifyArtistId: validated.spotifyArtistId,
});

if (!artist) {
return NextResponse.json(
Expand All @@ -39,7 +40,11 @@ export async function createArtistPostHandler(request: NextRequest): Promise<Nex
);
}

return NextResponse.json({ artist }, { status: 201, headers: getCorsHeaders() });
// 200 = existing canonical linked to the account; 201 = created (chat#1889 row 8).
return NextResponse.json(
{ artist },
{ status: created ? 201 : 200, headers: getCorsHeaders() },
);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create artist";
return NextResponse.json(
Expand Down
68 changes: 68 additions & 0 deletions lib/artists/resolveOrCreateArtist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { createArtistInDb, type CreateArtistResult } from "@/lib/artists/createArtistInDb";
import { findCanonicalArtistBySpotifyId } from "@/lib/valuation/findCanonicalArtistBySpotifyId";
import { selectAccountArtistId } from "@/lib/supabase/account_artist_ids/selectAccountArtistId";
import { insertAccountArtistId } from "@/lib/supabase/account_artist_ids/insertAccountArtistId";
import { selectAccountWithSocials } from "@/lib/supabase/accounts/selectAccountWithSocials";
import { updateArtistSocials } from "@/lib/artist/updateArtistSocials";

export interface ResolveOrCreateArtistParams {
name: string;
accountId: string;
organizationId?: string;
/** When present, resolve-or-create the canonical artist for this Spotify id. */
spotifyArtistId?: string;
}

export interface ResolveOrCreateArtistResult {
artist: CreateArtistResult | null;
/** false when an existing canonical was linked instead of created (→ 200, not 201). */
created: boolean;
}

/**
* Creates an artist — or, when `spotifyArtistId` names an artist that already
* exists, links that canonical to the account instead of minting a duplicate.
*
* One canonical artist per Spotify id (chat#1889, decision 2026-07-29):
* artists are shared across accounts via `account_artist_ids`, so creation is
* the only place dedup can be enforced. Every add path minting its own row is
* what made downstream canonical lookups ambiguous and doubled rosters.
*
* The social attach on the create path is non-fatal (same contract as the
* chat-side add, chat#1892): the row exists by then, and a failed attach must
* not fail the add — the social is still fixable in verify-socials.
*/
export async function resolveOrCreateArtist(
params: ResolveOrCreateArtistParams,
): Promise<ResolveOrCreateArtistResult> {
const { name, accountId, organizationId, spotifyArtistId } = params;

if (spotifyArtistId) {
const canonicalId = await findCanonicalArtistBySpotifyId(spotifyArtistId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: A non-empty spotifyArtistId containing % or _ is treated as an ILIKE wildcard, so a caller can resolve and link an unrelated artist instead of the requested ID. Restrict the ID to the accepted Spotify-ID format or escape ILIKE metacharacters before lookup and URL construction.

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

<comment>A non-empty `spotifyArtistId` containing `%` or `_` is treated as an `ILIKE` wildcard, so a caller can resolve and link an unrelated artist instead of the requested ID. Restrict the ID to the accepted Spotify-ID format or escape `ILIKE` metacharacters before lookup and URL construction.</comment>

<file context>
@@ -0,0 +1,68 @@
+  const { name, accountId, organizationId, spotifyArtistId } = params;
+
+  if (spotifyArtistId) {
+    const canonicalId = await findCanonicalArtistBySpotifyId(spotifyArtistId);
+    if (canonicalId) {
+      const alreadyRostered = await selectAccountArtistId(accountId, canonicalId);
</file context>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Concurrent requests can both observe no canonical and create separate artist accounts because the lookup and creation are not atomic; the later social upsert does not make the artist rows unique. An atomic database resolve/create operation with a unique Spotify identity is needed to preserve the one-canonical-per-ID contract.

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

<comment>Concurrent requests can both observe no canonical and create separate artist accounts because the lookup and creation are not atomic; the later social upsert does not make the artist rows unique. An atomic database resolve/create operation with a unique Spotify identity is needed to preserve the one-canonical-per-ID contract.</comment>

<file context>
@@ -0,0 +1,68 @@
+  const { name, accountId, organizationId, spotifyArtistId } = params;
+
+  if (spotifyArtistId) {
+    const canonicalId = await findCanonicalArtistBySpotifyId(spotifyArtistId);
+    if (canonicalId) {
+      const alreadyRostered = await selectAccountArtistId(accountId, canonicalId);
</file context>

if (canonicalId) {
const alreadyRostered = await selectAccountArtistId(accountId, canonicalId);

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: Concurrent reuse requests can race between the roster existence check and insert, causing duplicate account_artist_ids rows or a 500 on a uniqueness conflict. An idempotent database upsert/insert-on-conflict operation would make the reuse path safe.

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

<comment>Concurrent reuse requests can race between the roster existence check and insert, causing duplicate `account_artist_ids` rows or a 500 on a uniqueness conflict. An idempotent database upsert/insert-on-conflict operation would make the reuse path safe.</comment>

<file context>
@@ -0,0 +1,68 @@
+  if (spotifyArtistId) {
+    const canonicalId = await findCanonicalArtistBySpotifyId(spotifyArtistId);
+    if (canonicalId) {
+      const alreadyRostered = await selectAccountArtistId(accountId, canonicalId);
+      if (!alreadyRostered) {
+        await insertAccountArtistId(accountId, canonicalId);
</file context>

if (!alreadyRostered) {
await insertAccountArtistId(accountId, canonicalId);
}
const artist = await selectAccountWithSocials(canonicalId);
return {
artist: artist ? { ...artist, account_id: artist.id } : null,
created: false,
};
}
}

const created = await createArtistInDb(name, accountId, organizationId);
Comment on lines +40 to +55

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:

#!/usr/bin/env bash
rg -n -C 5 'findCanonicalArtistBySpotifyId|createArtistInDb|selectAccountArtistId|insertAccountArtistId' lib
rg -n -C 3 'spotify|account_artist_ids|unique' supabase lib

Repository: recoupable/api

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files containing resolveOrCreateArtist:"
fd -a 'resolveOrCreateArtist\.ts$' . | sed 's#^\./##'

file="$(fd 'resolveOrCreateArtist\.ts$' . | head -n 1)"
echo "---- $file ----"
wc -l "$file"
ast-grep outline "$file" --view expanded || true
sed -n '1,180p' "$file" | cat -n

echo "---- Supabase table definitions around artists/socials/account_artist_ids ----"
rg -n -C 8 'artists|spotify_id|account_artist|account_social|i[._-]?new|index|unique|constraint|account_socials' supabase lib | head -n 260

Repository: recoupable/api

Length of output: 23226


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Candidate artifact files:"
git ls-files | rg '(^|/)(artist|artists|account_artist|artist_social|account_social|account_socials|spotify).*(sql|ts)$|supabase/.*/.*(artist|spotify|social).*' | head -n 300

for f in $(git ls-files | rg '(^|/)(artist|artists|spotify|socials?).*\.(sql|ts)$' | head -n 80); do
  echo "---- $f ----"
  wc -l "$f"
  sed -n '1,220p' "$f" | cat -n
done

Repository: recoupable/api

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate Supabase schema and migrations:"
git ls-files | rg '(^|/)(supabase|migrations|.*\.sql$)' | head -n 200

echo "---- Files directly named around artists/socials/account_artist_ids sql ----"
git ls-files | rg '(artist|artists|account_artist|artist_social|account_social|socials).*\.sql$' | sort

echo "---- Relevant SQL snippets ----"
for f in $(git ls-files | rg '(artist|artists|account_artist|artist_social|account_social|socials).*\.sql$' | sort | head -n 80); do
  echo "---- $f ----"
  rg -n -C 8 'artists|spotify|unique|unique .*spotify|account_artist|account_social|unique .*account.*social|constraint.*account.*social|i[._-]?new|create table' "$f" || true
done

Repository: recoupable/api

Length of output: 11060


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- candidate value/spotify canonical files ----"
git ls-files | rg '(^|/)findCanonicalArtistBySpotifyId\.ts$|spotify|artist' | head -n 200

echo "---- implementation files ----"
for f in $(git ls-files | rg '(^|/)findCanonicalArtistBySpotifyId\.ts$|(^|/)createArtistInDb\.ts$|(^|/)(insertAccountArtistId|selectAccountArtistId|updateArtistSocials|insertAccountSocial|socials).*\.ts$' | sort); do
  echo "---- $f ----"
  wc -l "$f"
  ast-grep outline "$f" --view expanded || true
  sed -n '1,220p' "$f" | cat -n
done

echo "---- schema searches for SPOTIFY/social/account_artist constraints ----"
rg -n -C 6 'SPOTIFY|account_socials|socials|account_artist_ids|spotify|create table|constraint|unique|create unique|i[_-]?new|unique constraint|unique' lib/supabase/lib/supabase | head -n 350

Repository: recoupable/api

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- target implementation files ----"
for f in \
  lib/valuation/findCanonicalArtistBySpotifyId.ts \
  lib/artists/createArtistInDb.ts \
  lib/supabase/account_artist_ids/selectAccountArtistId.ts \
  lib/supabase/account_artist_ids/insertAccountArtistId.ts \
  lib/supabase/accounts/selectAccountWithSocials.ts \
  lib/supabase/account_socials/insertAccountSocial.ts \
  lib/supabase/socials/selectSocials.ts \
  lib/supabase/socials/upsertSocials.ts
do
  if [ -f "$f" ]; then
    echo "---- $f ----"
    wc -l "$f"
    sed -n '1,220p' "$f" | cat -n
  else
    echo "MISSING: $f"
  fi
done

echo "---- schema/migration SQL files ----"
git ls-files \*.{sql|ddl}| head -n 200
for f in $(git ls-files \*.{sql|ddl} | head -n 200); do
  echo "---- $f: relevant schema matches ----"
  rg -n -C 5 'create table|account_socials|socials|account_artist_ids|spotify|unique constraint|unique|SPOTIFY|profile_url' "$f" | head -n 180
done

Repository: recoupable/api

Length of output: 12821


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- all SQL files ----"
git ls-files | rg '\.sql$' | sort

echo "---- schema/migration relevant constraints ----"
for f in $(git ls-files | rg '\.sql$' | sort); do
  matches=$(rg -n -C 5 'account_socials|socials|account_artist_ids|spotify|unique constraint|unique|SPOTIFY|profile_url' "$f" || true)
  if [ -n "$matches" ]; then
    echo "---- $f ----"
    echo "$matches"
  fi
done

echo "---- deterministic race-order probe ----"
python3 - <<'PY'
sequence = [
    ("A", "findCanonical", None),
    ("B", "findCanonical", None),
    ("A", "createArtistInDb", "artist-A"),
    ("B", "createArtistInDb", "artist-B"),
    ("A", "updateArtistSocials", "artist-A"),
    ("B", "updateArtistSocials", "artist-B"),
]
for step in sequence:
    print(f"{step[0]}: {step[1]} -> {step[2]}")

print("\nTwo artist rows exist after interleaved createArtistInDb calls, each with same Spotify social: duplicate canonical artist rows.")
PY

Repository: recoupable/api

Length of output: 176


Make canonical artist lookup, social attach, and roster linking atomic.

findCanonicalArtistBySpotifyId() only searches socials by substring, while the artist/social row may not exist until after createArtistInDb() returns. Concurrent requests can each miss the canonical artist, insert separate artist rows, then attach the same Spotify social. The later selectAccountArtistId() / insertAccountArtistId() sequence has the same issue and can also generate duplicate roster links.

Enforce one canonical Spotify artist in the schema, use an atomic upsert/transaction for create + social attach, and use an idempotent or conflict-safe insert for account_artist_ids.

🤖 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/artists/resolveOrCreateArtist.ts` around lines 40 - 55, Make the Spotify
artist resolution and creation flow in resolveOrCreateArtist atomic: enforce a
unique canonical Spotify social identity in the schema, then use a transaction
or atomic upsert to combine artist creation with Spotify social attachment so
concurrent requests cannot create duplicate artists. Replace the
selectAccountArtistId/insertAccountArtistId sequence with an idempotent or
conflict-safe roster-link insert, preserving the existing return behavior for
the canonical artist.


if (created && spotifyArtistId) {
try {
await updateArtistSocials(created.account_id, {
SPOTIFY: `https://open.spotify.com/artist/${spotifyArtistId}`,
});
} catch {
// Non-fatal by design — see docstring.
}
}

return { artist: created, created: true };
}
Loading
Loading