| import type { |
| PostgrestSingleResponse, |
| PostgrestResponseSuccess, |
| CheckMatchingArrayTypes, |
| MergePartialResult, |
| IsValidResultOverride, |
| } from './types/types' |
| import { |
| ClientServerOptions, |
| Fetch, |
| DEFAULT_MAX_RETRIES, |
| getRetryDelay, |
| RETRYABLE_STATUS_CODES, |
| RETRYABLE_METHODS, |
| } from './types/common/common' |
| import PostgrestError from './PostgrestError' |
| import { ContainsNull } from './select-query-parser/types' |
|
|
| |
| |
| |
| |
| function sleep(ms: number, signal?: AbortSignal): Promise<void> { |
| return new Promise((resolve) => { |
| if (signal?.aborted) { |
| resolve() |
| return |
| } |
| const id = setTimeout(() => { |
| signal?.removeEventListener('abort', onAbort) |
| resolve() |
| }, ms) |
| function onAbort() { |
| clearTimeout(id) |
| resolve() |
| } |
| signal?.addEventListener('abort', onAbort) |
| }) |
| } |
|
|
| |
| |
| |
| function shouldRetry( |
| method: string, |
| status: number, |
| attemptCount: number, |
| retryEnabled: boolean |
| ): boolean { |
| |
| if (!retryEnabled || attemptCount >= DEFAULT_MAX_RETRIES) { |
| return false |
| } |
|
|
| |
| if (!RETRYABLE_METHODS.includes(method as (typeof RETRYABLE_METHODS)[number])) { |
| return false |
| } |
|
|
| |
| if (!RETRYABLE_STATUS_CODES.includes(status as (typeof RETRYABLE_STATUS_CODES)[number])) { |
| return false |
| } |
|
|
| return true |
| } |
|
|
| export default abstract class PostgrestBuilder< |
| ClientOptions extends ClientServerOptions, |
| Result, |
| ThrowOnError extends boolean = false, |
| > implements PromiseLike< |
| ThrowOnError extends true ? PostgrestResponseSuccess<Result> : PostgrestSingleResponse<Result> |
| > { |
| protected method: 'GET' | 'HEAD' | 'POST' | 'PATCH' | 'DELETE' |
| protected url: URL |
| protected headers: Headers |
| protected schema?: string |
| protected body?: unknown |
| protected shouldThrowOnError = false |
| protected signal?: AbortSignal |
| protected fetch: Fetch |
| protected isMaybeSingle: boolean |
| protected shouldStripNulls: boolean |
| protected urlLengthLimit: number |
|
|
| |
| protected retryEnabled: boolean = true |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| constructor(builder: { |
| method: 'GET' | 'HEAD' | 'POST' | 'PATCH' | 'DELETE' |
| url: URL |
| headers: HeadersInit |
| schema?: string |
| body?: unknown |
| shouldThrowOnError?: boolean |
| signal?: AbortSignal |
| fetch?: Fetch |
| isMaybeSingle?: boolean |
| shouldStripNulls?: boolean |
| urlLengthLimit?: number |
| // Retry option |
| retry?: boolean |
| }) { |
| this.method = builder.method |
| this.url = builder.url |
| this.headers = new Headers(builder.headers) |
| this.schema = builder.schema |
| this.body = builder.body |
| this.shouldThrowOnError = builder.shouldThrowOnError ?? false |
| this.signal = builder.signal |
| this.isMaybeSingle = builder.isMaybeSingle ?? false |
| this.shouldStripNulls = builder.shouldStripNulls ?? false |
| this.urlLengthLimit = builder.urlLengthLimit ?? 8000 |
| this.retryEnabled = builder.retry ?? true |
|
|
| if (builder.fetch) { |
| this.fetch = builder.fetch |
| } else { |
| this.fetch = fetch |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| throwOnError(): PostgrestBuilder<ClientOptions, Result, true> { |
| this.shouldThrowOnError = true |
| return this as PostgrestBuilder<ClientOptions, Result, true> |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| stripNulls(): this { |
| if (this.headers.get('Accept') === 'text/csv') { |
| throw new Error('stripNulls() cannot be used with csv()') |
| } |
| this.shouldStripNulls = true |
| return this |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| setHeader(name: string, value: string): this { |
| this.headers = new Headers(this.headers) |
| this.headers.set(name, value) |
| return this |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| retry(enabled: boolean): this { |
| this.retryEnabled = enabled |
| return this |
| } |
|
|
| then< |
| TResult1 = ThrowOnError extends true |
| ? PostgrestResponseSuccess<Result> |
| : PostgrestSingleResponse<Result>, |
| TResult2 = never, |
| >( |
| onfulfilled?: |
| | (( |
| value: ThrowOnError extends true |
| ? PostgrestResponseSuccess<Result> |
| : PostgrestSingleResponse<Result> |
| ) => TResult1 | PromiseLike<TResult1>) |
| | undefined |
| | null, |
| onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null |
| ): PromiseLike<TResult1 | TResult2> { |
| |
| if (this.schema === undefined) { |
| |
| } else if (['GET', 'HEAD'].includes(this.method)) { |
| this.headers.set('Accept-Profile', this.schema) |
| } else { |
| this.headers.set('Content-Profile', this.schema) |
| } |
| if (this.method !== 'GET' && this.method !== 'HEAD') { |
| this.headers.set('Content-Type', 'application/json') |
| } |
|
|
| |
| if (this.shouldStripNulls) { |
| const currentAccept = this.headers.get('Accept') |
| if (currentAccept === 'application/vnd.pgrst.object+json') { |
| this.headers.set('Accept', 'application/vnd.pgrst.object+json;nulls=stripped') |
| } else if (!currentAccept || currentAccept === 'application/json') { |
| this.headers.set('Accept', 'application/vnd.pgrst.array+json;nulls=stripped') |
| } |
| } |
|
|
| |
| |
| const _fetch = this.fetch |
|
|
| |
| const executeWithRetry = async (): Promise<{ |
| error: any |
| data: any |
| count: number | null |
| status: number |
| statusText: string |
| }> => { |
| let attemptCount = 0 |
|
|
| while (true) { |
| |
| |
| |
| |
| |
| const headers: Record<string, string> = {} |
| this.headers.forEach((value, key) => { |
| headers[key] = value |
| }) |
| if (attemptCount > 0) { |
| headers['X-Retry-Count'] = String(attemptCount) |
| } |
|
|
| |
| let res: Response |
| try { |
| res = await _fetch(this.url.toString(), { |
| method: this.method, |
| headers, |
| body: JSON.stringify(this.body, (_, value) => |
| typeof value === 'bigint' ? value.toString() : value |
| ), |
| signal: this.signal, |
| }) |
| |
| |
| |
| |
| } catch (fetchError: any) { |
| |
| if (fetchError?.name === 'AbortError' || fetchError?.code === 'ABORT_ERR') { |
| throw fetchError |
| } |
|
|
| |
| if (!RETRYABLE_METHODS.includes(this.method as (typeof RETRYABLE_METHODS)[number])) { |
| throw fetchError |
| } |
|
|
| |
| if (this.retryEnabled && attemptCount < DEFAULT_MAX_RETRIES) { |
| const delay = getRetryDelay(attemptCount) |
| attemptCount++ |
| await sleep(delay, this.signal) |
| continue |
| } |
|
|
| |
| throw fetchError |
| } |
|
|
| |
| if (shouldRetry(this.method, res.status, attemptCount, this.retryEnabled)) { |
| const retryAfterHeader = res.headers?.get('Retry-After') ?? null |
| const delay = |
| retryAfterHeader !== null |
| ? Math.max(0, parseInt(retryAfterHeader, 10) || 0) * 1000 |
| : getRetryDelay(attemptCount) |
| await res.text() |
| attemptCount++ |
| await sleep(delay, this.signal) |
| continue |
| } |
|
|
| return await this.processResponse(res) |
| } |
| } |
|
|
| let res = executeWithRetry() |
|
|
| if (!this.shouldThrowOnError) { |
| res = res.catch((fetchError) => { |
| |
| |
| |
| let errorDetails = '' |
| let hint = '' |
| let code = '' |
|
|
| |
| const cause = fetchError?.cause |
| if (cause) { |
| const causeMessage = cause?.message ?? '' |
| const causeCode = cause?.code ?? '' |
|
|
| errorDetails = `${fetchError?.name ?? 'FetchError'}: ${fetchError?.message}` |
| errorDetails += `\n\nCaused by: ${cause?.name ?? 'Error'}: ${causeMessage}` |
| if (causeCode) { |
| errorDetails += ` (${causeCode})` |
| } |
| if (cause?.stack) { |
| errorDetails += `\n${cause.stack}` |
| } |
| } else { |
| |
| errorDetails = fetchError?.stack ?? '' |
| } |
|
|
| |
| const urlLength = this.url.toString().length |
|
|
| |
| if (fetchError?.name === 'AbortError' || fetchError?.code === 'ABORT_ERR') { |
| code = '' |
| hint = 'Request was aborted (timeout or manual cancellation)' |
|
|
| if (urlLength > this.urlLengthLimit) { |
| hint += `. Note: Your request URL is ${urlLength} characters, which may exceed server limits. If selecting many fields, consider using views. If filtering with large arrays (e.g., .in('id', [many IDs])), consider using an RPC function to pass values server-side.` |
| } |
| } |
| |
| else if ( |
| cause?.name === 'HeadersOverflowError' || |
| cause?.code === 'UND_ERR_HEADERS_OVERFLOW' |
| ) { |
| code = '' |
| hint = 'HTTP headers exceeded server limits (typically 16KB)' |
|
|
| if (urlLength > this.urlLengthLimit) { |
| hint += `. Your request URL is ${urlLength} characters. If selecting many fields, consider using views. If filtering with large arrays (e.g., .in('id', [200+ IDs])), consider using an RPC function instead.` |
| } |
| } |
|
|
| return { |
| success: false as const, |
| error: { |
| message: `${fetchError?.name ?? 'FetchError'}: ${fetchError?.message}`, |
| details: errorDetails, |
| hint: hint, |
| code: code, |
| }, |
| data: null, |
| count: null, |
| status: 0, |
| statusText: '', |
| } |
| }) |
| } |
|
|
| return ( |
| res as Promise< |
| ThrowOnError extends true |
| ? PostgrestResponseSuccess<Result> |
| : PostgrestSingleResponse<Result> |
| > |
| ).then(onfulfilled, onrejected) |
| } |
|
|
| |
| |
| |
| private async processResponse(res: Response): Promise<{ |
| success: boolean |
| error: any |
| data: any |
| count: number | null |
| status: number |
| statusText: string |
| }> { |
| let error = null |
| let data = null |
| let count: number | null = null |
| let status = res.status |
| let statusText = res.statusText |
|
|
| if (res.ok) { |
| if (this.method !== 'HEAD') { |
| const body = await res.text() |
| if (body === '') { |
| |
| } else if (this.headers.get('Accept') === 'text/csv') { |
| data = body |
| } else if ( |
| this.headers.get('Accept') && |
| this.headers.get('Accept')?.includes('application/vnd.pgrst.plan+text') |
| ) { |
| data = body |
| } else { |
| try { |
| data = JSON.parse(body) |
| } catch { |
| |
| error = { message: body } |
| data = null |
|
|
| if (this.shouldThrowOnError) { |
| throw new PostgrestError({ message: body, details: '', hint: '', code: '' }) |
| } |
| } |
| } |
| } |
|
|
| const countHeader = this.headers.get('Prefer')?.match(/count=(exact|planned|estimated)/) |
| const contentRange = res.headers.get('content-range')?.split('/') |
| if (countHeader && contentRange && contentRange.length > 1) { |
| count = parseInt(contentRange[1]) |
| } |
|
|
| |
| if (this.isMaybeSingle && Array.isArray(data)) { |
| if (data.length > 1) { |
| error = { |
| |
| code: 'PGRST116', |
| details: `Results contain ${data.length} rows, application/vnd.pgrst.object+json requires 1 row`, |
| hint: null, |
| message: 'JSON object requested, multiple (or no) rows returned', |
| } |
| data = null |
| count = null |
| status = 406 |
| statusText = 'Not Acceptable' |
| } else if (data.length === 1) { |
| data = data[0] |
| } else { |
| data = null |
| } |
| } |
| } else { |
| const body = await res.text() |
|
|
| try { |
| error = JSON.parse(body) |
|
|
| |
| if (Array.isArray(error) && res.status === 404) { |
| data = [] |
| error = null |
| status = 200 |
| statusText = 'OK' |
| } |
| } catch { |
| |
| if (res.status === 404 && body === '') { |
| status = 204 |
| statusText = 'No Content' |
| } else { |
| error = { |
| message: body, |
| } |
| } |
| } |
|
|
| if (error && this.shouldThrowOnError) { |
| throw new PostgrestError(error) |
| } |
| } |
|
|
| return { |
| success: error === null, |
| error, |
| data, |
| count, |
| status, |
| statusText, |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| returns<NewResult>(): PostgrestBuilder< |
| ClientOptions, |
| CheckMatchingArrayTypes<Result, NewResult>, |
| ThrowOnError |
| > { |
| |
| return this as unknown as PostgrestBuilder< |
| ClientOptions, |
| CheckMatchingArrayTypes<Result, NewResult>, |
| ThrowOnError |
| > |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| overrideTypes< |
| NewResult, |
| Options extends { merge?: boolean } = { merge: true }, |
| >(): PostgrestBuilder< |
| ClientOptions, |
| IsValidResultOverride<Result, NewResult, false, false> extends true |
| ? |
| ContainsNull<Result> extends true |
| ? MergePartialResult<NewResult, NonNullable<Result>, Options> | null |
| : MergePartialResult<NewResult, Result, Options> |
| : CheckMatchingArrayTypes<Result, NewResult>, |
| ThrowOnError |
| > { |
| return this as unknown as PostgrestBuilder< |
| ClientOptions, |
| IsValidResultOverride<Result, NewResult, false, false> extends true |
| ? |
| ContainsNull<Result> extends true |
| ? MergePartialResult<NewResult, NonNullable<Result>, Options> | null |
| : MergePartialResult<NewResult, Result, Options> |
| : CheckMatchingArrayTypes<Result, NewResult>, |
| ThrowOnError |
| > |
| } |
| } |
|
|