supabase / supabase-js

An isomorphic Javascript client for Supabase. Query your Supabase database, subscribe to realtime events, upload and download files, browse typescript examples, invoke postgres functions via rpc, invoke supabase edge functions, query pgvector.
https://supabase.com
MIT License
2.86k stars 220 forks source link

supabase-js .single() returning array #536

Closed Daynil closed 1 year ago

Daynil commented 1 year ago

Bug report

Describe the bug

After updating to supabase-js v2 rc-1, appending .single() to a query continues to return an array in the actual data. The types from the new type gen properly infer that a single non-array item should be returned when .single() is used, so manually specifying data[0] produces an any type inference. However, logging data shows that it was returned as an array. The same code works as expected in the latest supabase-js v1.

To Reproduce

const { data, error } = await supabase
    .from('my_table')
    .select(
    `
    *,
    my_relation (
      *
    )
  `
    )
    .eq('id', itemId)
    .single();

// `data` is inferenced correctly as a single `my_table` item
// Additionally, error is null, so .single() did not throw, since there was 1 item returned
// however, data itself is logged as an array of length 1 of the item
// `[{ /* ...itemContents */ }]`
console.log(data);

Expected behavior

.single() should cause data to return the single object (or throw if multiple) not an array of the object (as inferred by the types).

System information

soedirgo commented 1 year ago

Is this still happening? I couldn't reproduce this in 2.0.0-rc.1 or the latest rc version.

Daynil commented 1 year ago

Thanks for looking into this @soedirgo!

I believe I have pinpointed the issue. I am using a custom fetch implementation, and my fetch call was not receiving the vnd.pgrst.object header. Likewise, I'm not receiving other headers, like headers['Content-Type'] = 'application/json'; for post requests or headers['prefer'] = 'return=representation' for non-GET requests.

Would you be able to provide any guidance on why I'm not receiving any headers from supabase here? I used the guidance here.

const supabase = createClient<Database>(
    appConfig.frontend.supabase.url,
    appConfig.frontend.supabase.anonKey,
    {
        global: {
            fetch: supabaseFetch.bind(globalThis)
        }
    }
);

export async function supabaseFetch(
    input: RequestInfo | URL,
    init?: RequestInit
) {
        // **** Input contains the fully constructed URL from supabase
        // i.e. project.supabase.co/rest/v1/my_table?id=eq.1
    console.log('from supabase input: ', input);

        // **** init contains method 'GET', but the headers object is empty
        // should this contain {Accept: 'application/vnd.pgrst.object+json'} after a .single() call?
        // If not, how else can I determine .single was called in my custom fetch?
       // i.e. {method: 'GET', headers: {}, body: undefined, signal: undefined}
    console.log('from supabase init: ', init);

    const authHeader: { Authorization: string; apikey: string } = {
        Authorization: `Bearer ${await getSupabaseToken()}`,
        apikey: appConfig.frontend.supabase.anonKey
    };

    const headers: HeadersInit = init.headers
        ? { ...init.headers, ...authHeader }
        : authHeader;

    if (init.body) {
        // https://postgrest.org/en/stable/api.html#calling-functions-with-a-single-unnamed-parameter
        headers['Content-Type'] = 'application/json';
        // body already comes stringified from supabase-js
        // init.body = JSON.stringify(init.body);
    }

    if (init.method !== 'GET' && init.method !== 'OPTIONS') {
        // Required to get data back from non-GET requests
        // https://postgrest.org/en/stable/api.html#insertions-updates
        headers['prefer'] = 'return=representation';
    }

        // **** How do I know when to set this?
    if (init.method === 'GET') {
        headers['Accept'] = 'application/vnd.pgrst.object+json';
    }

    // Cast as RequestInfo solves a type error only found in the compiler for some reason
    return fetch(input as RequestInfo, {
        ...init,
        headers
    });
}
soedirgo commented 1 year ago

Hmm, can you elaborate what you're aiming for with the custom fetch?

init contains method 'GET', but the headers object is empty

That's odd - it should have headers populated, incl. auth headers and Accept. Does it work if you don't pass a custom fetch?

Daynil commented 1 year ago

Yeah, I'm using it to sync with an external auth provider. I need to sync the custom supabase auth key every 30 minutes since it regenerates. Previously, I was using supabase.auth.setAuth(token) similar to the process described here. Since supabase v2 has removed setAuth, I've been considering the alternatives and this has been my favorite, other than the issue above with init.headers not getting passed through.

I looked into the suggestion in the removal PR to pass the custom header into createClient(), but since the authentication token refreshes I can't just set it once. Also the call to get the first token is async, so that makes it tricky to export. I guess the other option is to recreate the client for every request, but that doesn't seem as clean/efficient as injecting your headers via the custom fetch?

That's odd - it should have headers populated, incl. auth headers and Accept. Does it work if you don't pass a custom fetch?

It does work fine without the custom fetch. I tried to create a minimal reproduction here (single table todos with two items, id's 1 and 2) and discovered that it does in fact pass an empty header into the custom fetch call. However, if I just pass both properties back to fetch without any other modification, in the network tab, all headers are set properly. If I try to set a custom header that supabase has already set (such as the one I need, Authorization), supabase seems to automatically overwrite it to the default (which is the anon key). The only way for me to get my custom Authorization header set is to completely overwrite the headers object with my own, in which case I need to manually reconstruct the appropriate headers since we aren't getting passed them in the custom fetch call, and for the .single() call, we can't know whether it was called with single in the custom fetch.

soedirgo commented 1 year ago

Thanks for the thorough explanation! I'll have a look at the repro link.

soedirgo commented 1 year ago

Sorry for the late reply - had a look at the repro link (thanks again!) and it seems like this was caused by init.headers being a Headers object instead of a { [key: string]: string }. You can get the actual values if you do:

for (const [headerKey, headerValue] of init?.headers?.entries() ?? []) {
  console.log('header:', headerKey, headerValue)
}

That said, we don't make any guarantee that this will always be Headers, so you'll need to handle both cases.

Daynil commented 1 year ago

Wow, can't believe I haven't run into this particular javascript idiosyncrasy until now! Thanks @soedirgo!!

I adjusted my code and everything works exactly as expected:

export async function supabaseFetch(
  input: RequestInfo | URL,
  init?: RequestInit
) {
    if (init.headers instanceof Headers) {
        init.headers.set('Authorization', `Bearer ${await getSupabaseToken()}`);
    } else {
        init.headers['Authorization'] = `Bearer ${await getSupabaseToken()}`;
    }
    return fetch(input, init);
}
Aldo111 commented 1 year ago

Hi I ran into this issue as well recently in a NextJS project in a server component but in a peculiar way.

I was doing

const { data, error } = await supabase
    .from('mytable')
    .select('*')
    .eq('slug', slug)
    .single();

This returns an array with one object.

However if I add a second condition: (.eq('column', true')) to the first, then it just returns an object and not an array.

This is in NextJS in a server component but I'm not doing anything fancy other than this read.

Is this expected behavior of some kind/when does .single() still return an array?

soedirgo commented 1 year ago

It's not expected behavior - do you have a public repo that demonstrates the issue so I can reproduce it?

JBhrayn commented 1 month ago

Just encountered this issue too. I'm using NextJS. image