[]previous and next page

Supabase

Supabase is Postgres behind a REST API (PostgREST), authentication and storage.

For agentsUpdated

Supabase is Postgres behind a REST API (PostgREST), authentication and storage. From a Dusk server you have two choices: the JavaScript client below, or a direct Postgres connection with Drizzle's postgres-js or node-postgres driver (the same pattern as the libSQL module above, with a pooled connection string). The client is the right choice when you want Supabase's row-level security to apply, because it sends the user's token with each request.

Two keys exist, and they are different in kind. The anon key is public and is safe in a browser: row-level security decides what it may read. The service-role key bypasses row-level security: it belongs in a .server module and nowhere else.

Type the database once. supabase gen types typescript writes this shape from the live schema.

src/server/database.tsts
export interface Database {
  public: {
    Tables: {
      notes: {
        Row: { id: number; title: string; owner: string };
        Insert: { id?: number; title: string; owner: string };
        Update: { id?: number; title?: string; owner?: string };
        Relationships: [];
      };
    };
    Views: Record<string, never>;
    Functions: Record<string, never>;
    Enums: Record<string, never>;
    CompositeTypes: Record<string, never>;
  };
}

The client of a request carries that request's user. Build it per request from the token the browser sent, so row-level security sees the right user; never share one client that holds a user's token between requests.

src/server/supabase.server.tsts
import { createClient } from "@supabase/supabase-js";
import type { Database } from "./database";

/**
 * A client for one request.
 *
 * @param accessToken The user's JWT from the request, or `undefined` for an anonymous visitor.
 * @param transport The `fetch` to use; a test passes its own.
 */
export function supabaseFor(accessToken: string | undefined, transport?: typeof fetch) {
  return createClient<Database>(
    process.env["SUPABASE_URL"] ?? "http://localhost:54321",
    process.env["SUPABASE_ANON_KEY"] ?? "anon-key-for-tests",
    {
      auth: { persistSession: false, autoRefreshToken: false },
      global: {
        ...(accessToken === undefined ? {} : { headers: { Authorization: `Bearer ${accessToken}` } }),
        ...(transport === undefined ? {} : { fetch: transport }),
      },
    },
  );
}

supabase-js does not throw. A failed query returns { data: null, error }, so every call checks error. Map the ones the caller can fix and let the rest become a 500.

src/server/supabase-notes.tsts
import { HttpError } from "@shadow-js/dusk/server";
import { supabaseFor } from "./supabase.server";

export interface PublicNote {
  id: number;
  title: string;
}

/** The notes the user may read, newest first. Row-level security decides which rows come back. */
export async function readNotes(accessToken: string | undefined, transport?: typeof fetch): Promise<PublicNote[]> {
  const { data, error } = await supabaseFor(accessToken, transport)
    .from("notes")
    .select("id,title")
    .order("id", { ascending: false });
  if (error !== null) {
    // 42501 is Postgres' "insufficient privilege": the policy refused the user.
    if (error.code === "42501") throw new HttpError(403, "Not allowed");
    throw new Error(`supabase: ${error.message}`);
  }
  return data;
}