import type { ConsoleClient } from '~/services/consoleClient' import type { Database } from '~/types/database.types' import { FunctionsFetchError, FunctionsHttpError } from '~/services/consoleClient' import { useConsole } from './console' export interface CapgoApiInvokeOptions { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' body?: BodyInit | Record | null headers?: Record /** * Opt into a dated `capgo_api` contract (e.g. `2026-10-22`). Omit to keep the * default version so older backends and other clients stay compatible. */ apiVersion?: string /** Call without an Authorization header when there is no user session (public bootstrap endpoints). */ allowAnonymous?: boolean /** Prefer caller-provided client for session/auth context. */ client?: ConsoleClient /** * Number of automatic retries for transient failures (network drops, edge * timeouts, and 5xx/429 responses from a stressed backend). Defaults to 2 for * idempotent requests (GET/HEAD) and 0 otherwise, so mutations are never * silently replayed. Pass 0 to opt out entirely. */ retries?: number } /** HTTP statuses that indicate a transient, retry-worthy backend failure. */ const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]) export function isRetryableStatus(status: number): boolean { return RETRYABLE_STATUS_CODES.has(status) } /** Only idempotent methods are auto-retried so a retry can't double-apply a mutation. */ function isIdempotentMethod(method: string): boolean { const normalized = method.toUpperCase() return normalized === 'GET' || normalized === 'HEAD' || normalized === 'OPTIONS' } export function defaultRetriesForMethod(method: string): number { return isIdempotentMethod(method) ? 2 : 0 } /** * True when an invoke error looks transient and safe to retry: a network-level * failure (`Failed to fetch` / `Failed to send a request to the Edge Function`) * or a retryable HTTP status. Non-2xx business errors (4xx) are not retried. */ export function isRetryableInvokeError(error: unknown): boolean { if (error instanceof FunctionsHttpError && error.context instanceof Response) return isRetryableStatus(error.context.status) if (error instanceof FunctionsFetchError) return true // Raw browser fetch failures surface as TypeError ("Failed to fetch"). if (error instanceof TypeError) return true const message = (error as { message?: unknown } | null)?.message return typeof message === 'string' && (message.includes('Failed to fetch') || message.includes('Failed to send a request to the Edge Function')) } /** Uniform fraction in [0, 1) from the crypto RNG (Math.random is flagged by static analysis). */ function jitterFraction(): number { const buffer = new Uint32Array(1) globalThis.crypto.getRandomValues(buffer) return buffer[0] / 2 ** 32 } /** Exponential backoff with full jitter, capped, so retries don't stampede a recovering backend. */ export function retryBackoffMs(attempt: number): number { const exponential = Math.min(3000, 300 * 2 ** attempt) return Math.round(exponential / 2 + jitterFraction() * (exponential / 2)) } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } /** * Run an invoke attempt, retrying transient failures up to `retries` times with * backoff. The attempt must resolve to the `{ data, error }` shape rather than * throwing, so every attempt shares the same retry policy. */ async function invokeWithRetry( attempt: () => Promise<{ data: T | null, error: Error | null }>, retries: number, ): Promise<{ data: T | null, error: Error | null }> { for (let i = 0; ; i++) { const result = await attempt() if (!result.error || i >= retries || !isRetryableInvokeError(result.error)) return result await sleep(retryBackoffMs(i)) } } function normalizeApiHost(host = ''): string { let end = host.length while (end > 0 && host[end - 1] === '/') end -= 1 return host.slice(0, end) } function serializeBody(body: CapgoApiInvokeOptions['body']): BodyInit | undefined { if (body == null) return undefined if ( typeof body === 'string' || body instanceof Blob || body instanceof FormData || body instanceof ArrayBuffer || ArrayBuffer.isView(body) ) { return body as BodyInit } return JSON.stringify(body) } function isPostgresUniqueViolation(message: string): boolean { return message.includes('23505') || /duplicate key value violates unique constraint/i.test(message) } /** Prefer Postgres unique-violation codes nested under Capgo API `moreInfo`. */ export async function getCapgoApiErrorCode(error: unknown): Promise { if (error instanceof FunctionsHttpError && error.context instanceof Response) { const body = await error.context.clone().json().catch(() => null) as { code?: string error?: string moreInfo?: { code?: string, error?: unknown } } | null const nestedCode = body?.moreInfo?.code if (typeof nestedCode === 'string') return nestedCode const nestedError = body?.moreInfo?.error if (typeof nestedError === 'string' && isPostgresUniqueViolation(nestedError)) return '23505' if (typeof body?.code === 'string') return body.code if (typeof body?.error === 'string') return body.error } const code = (error as { code?: unknown } | null)?.code return typeof code === 'string' ? code : undefined } /** The backend's own error name (`simpleError`'s first argument), ignoring nested provider codes. */ export async function getCapgoApiErrorName(error: unknown): Promise { if (!(error instanceof FunctionsHttpError) || !(error.context instanceof Response)) return undefined const body = await error.context.clone().json().catch(() => null) as { error?: unknown } | null return typeof body?.error === 'string' ? body.error : undefined } /** * Call the Capgo API (VITE_API_HOST) with a { data, error } result shape. * Cloud, self-host and local builds all use this single transport; the * console never talks to the database or auth provider directly. */ export async function invokeCapgoApi( path: string, options: CapgoApiInvokeOptions = {}, ): Promise<{ data: T | null, error: Error | null }> { const client = options.client ?? useConsole() const method = (options.method ?? 'POST').toUpperCase() const retries = options.retries ?? defaultRetriesForMethod(method) const headers: Record = { ...options.headers, } if (options.apiVersion && !Object.keys(headers).some(key => key.toLowerCase() === 'capgo_api')) headers.capgo_api = options.apiVersion const hasAuthHeader = Object.keys(headers).some(key => key.toLowerCase() === 'authorization') if (!hasAuthHeader) { const { data: sessionData } = await client.auth.getSession() const token = sessionData.session?.access_token if (token) { headers.Authorization = `Bearer ${token}` } else if (!options.allowAnonymous) { return { data: null, error: new Error('Not authenticated'), } } } const body = serializeBody(options.body) const hasContentType = Object.keys(headers).some(key => key.toLowerCase() === 'content-type') if (body != null && !hasContentType && !(body instanceof FormData)) headers['Content-Type'] = 'application/json' const apiHost = normalizeApiHost(import.meta.env.VITE_API_HOST as string) const url = `${apiHost}/${path.replace(/^\//, '')}` return invokeWithRetry(async () => { try { const response = await fetch(url, { method, headers, body: method === 'GET' || method === 'HEAD' ? undefined : body, }) if (!response.ok) { return { data: null, error: new FunctionsHttpError(response), } } const contentType = response.headers.get('content-type') ?? '' const payload = contentType.includes('application/json') ? await response.json().catch(() => null) : await response.text().catch(() => null) return { data: payload as T, error: null, } } catch (error) { return { data: null, error: error instanceof Error ? error : new Error(String(error)), } } }, retries) }