| type SupabaseEnv = { | |
| url: string; | |
| anonKey: string; | |
| serviceRoleKey?: string; | |
| }; | |
| function getSupabaseEnv(): SupabaseEnv { | |
| const url = process.env.NEXT_PUBLIC_SUPABASE_URL || ""; | |
| const anonKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY || ""; | |
| const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || ""; | |
| if (!url || !anonKey) { | |
| throw new Error("Supabase env vars missing: NEXT_PUBLIC_SUPABASE_URL and/or NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY"); | |
| } | |
| return { url, anonKey, serviceRoleKey: serviceRoleKey || undefined }; | |
| } | |
| export type SupabaseRestError = { | |
| message: string; | |
| details?: string; | |
| hint?: string; | |
| code?: string; | |
| }; | |
| export async function supabaseRest<T>( | |
| path: string, | |
| init: RequestInit & { | |
| query?: Record<string, string | number | boolean | undefined | null>; | |
| preferReturn?: "representation" | "minimal"; | |
| acceptObject?: boolean; | |
| count?: "exact" | "planned" | "estimated"; | |
| requireServiceRole?: boolean; | |
| } = {} | |
| ): Promise<{ data: T | null; error: SupabaseRestError | null; status: number; count: number | null }> { | |
| const { url, anonKey, serviceRoleKey } = getSupabaseEnv(); | |
| if (init.requireServiceRole && !serviceRoleKey) { | |
| throw new Error("Supabase env var missing: SUPABASE_SERVICE_ROLE_KEY (required for this operation)"); | |
| } | |
| const apiKey = init.requireServiceRole ? (serviceRoleKey as string) : anonKey; | |
| const endpoint = new URL(path.startsWith("/") ? path : `/${path}`, url); | |
| if (init.query) { | |
| for (const [k, v] of Object.entries(init.query)) { | |
| if (v === undefined || v === null) continue; | |
| endpoint.searchParams.set(k, String(v)); | |
| } | |
| } | |
| const headers = new Headers(init.headers); | |
| headers.set("apikey", apiKey); | |
| headers.set("Authorization", `Bearer ${apiKey}`); | |
| if (!headers.has("Content-Type") && init.body) { | |
| headers.set("Content-Type", "application/json"); | |
| } | |
| if (init.preferReturn) { | |
| const existing = headers.get("Prefer"); | |
| const next = `return=${init.preferReturn}`; | |
| headers.set("Prefer", existing ? `${existing}, ${next}` : next); | |
| } | |
| if (init.count) { | |
| const existing = headers.get("Prefer"); | |
| const next = `count=${init.count}`; | |
| headers.set("Prefer", existing ? `${existing}, ${next}` : next); | |
| } | |
| if (init.acceptObject) { | |
| headers.set("Accept", "application/vnd.pgrst.object+json"); | |
| } | |
| const res = await fetch(endpoint.toString(), { | |
| ...init, | |
| headers, | |
| }); | |
| const status = res.status; | |
| const contentRange = res.headers.get("content-range") || res.headers.get("Content-Range"); | |
| let count: number | null = null; | |
| if (contentRange) { | |
| const slash = contentRange.lastIndexOf("/"); | |
| if (slash !== -1) { | |
| const total = Number(contentRange.slice(slash + 1)); | |
| if (Number.isFinite(total)) count = total; | |
| } | |
| } | |
| const text = await res.text(); | |
| if (!res.ok) { | |
| let err: SupabaseRestError = { message: `Supabase REST error: ${status}` }; | |
| try { | |
| const parsed = JSON.parse(text); | |
| if (parsed && typeof parsed === "object") { | |
| err = { | |
| message: typeof (parsed as any).message === "string" ? (parsed as any).message : err.message, | |
| details: typeof (parsed as any).details === "string" ? (parsed as any).details : undefined, | |
| hint: typeof (parsed as any).hint === "string" ? (parsed as any).hint : undefined, | |
| code: typeof (parsed as any).code === "string" ? (parsed as any).code : undefined, | |
| }; | |
| } | |
| } catch { | |
| if (text) err = { message: text }; | |
| } | |
| return { data: null, error: err, status, count }; | |
| } | |
| if (!text) { | |
| return { data: null, error: null, status, count }; | |
| } | |
| try { | |
| return { data: JSON.parse(text) as T, error: null, status, count }; | |
| } catch { | |
| return { data: null, error: null, status, count }; | |
| } | |
| } | |