@supabase/postgrest-js 2.114.0 → 2.115.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supabase/postgrest-js",
3
- "version": "2.114.0",
3
+ "version": "2.115.0",
4
4
  "description": "Isomorphic PostgREST client",
5
5
  "keywords": [
6
6
  "postgrest",
@@ -5,66 +5,11 @@ import type {
5
5
  MergePartialResult,
6
6
  IsValidResultOverride,
7
7
  } from './types/types'
8
- import {
9
- ClientServerOptions,
10
- Fetch,
11
- DEFAULT_MAX_RETRIES,
12
- getRetryDelay,
13
- RETRYABLE_STATUS_CODES,
14
- RETRYABLE_METHODS,
15
- } from './types/common/common'
8
+ import { ClientServerOptions, Fetch } from './types/common/common'
16
9
  import PostgrestError from './PostgrestError'
10
+ import { fetchWithRetry } from './fetchWithRetry'
17
11
  import { ContainsNull } from './select-query-parser/types'
18
12
 
19
- /**
20
- * Sleep for a given number of milliseconds.
21
- * If an AbortSignal is provided, the sleep resolves early when the signal is aborted.
22
- */
23
- function sleep(ms: number, signal?: AbortSignal): Promise<void> {
24
- return new Promise((resolve) => {
25
- if (signal?.aborted) {
26
- resolve()
27
- return
28
- }
29
- const id = setTimeout(() => {
30
- signal?.removeEventListener('abort', onAbort)
31
- resolve()
32
- }, ms)
33
- function onAbort() {
34
- clearTimeout(id)
35
- resolve()
36
- }
37
- signal?.addEventListener('abort', onAbort)
38
- })
39
- }
40
-
41
- /**
42
- * Check if a request should be retried based on method and status code.
43
- */
44
- function shouldRetry(
45
- method: string,
46
- status: number,
47
- attemptCount: number,
48
- retryEnabled: boolean
49
- ): boolean {
50
- // Don't retry if retries are disabled or we've exhausted attempts
51
- if (!retryEnabled || attemptCount >= DEFAULT_MAX_RETRIES) {
52
- return false
53
- }
54
-
55
- // Only retry idempotent methods (GET, HEAD, OPTIONS)
56
- if (!RETRYABLE_METHODS.includes(method as (typeof RETRYABLE_METHODS)[number])) {
57
- return false
58
- }
59
-
60
- // Only retry on specific status codes (520 - Cloudflare errors)
61
- if (!RETRYABLE_STATUS_CODES.includes(status as (typeof RETRYABLE_STATUS_CODES)[number])) {
62
- return false
63
- }
64
-
65
- return true
66
- }
67
-
68
13
  export default abstract class PostgrestBuilder<
69
14
  ClientOptions extends ClientServerOptions,
70
15
  Result,
@@ -314,75 +259,32 @@ export default abstract class PostgrestBuilder<
314
259
  status: number
315
260
  statusText: string
316
261
  }> => {
317
- let attemptCount = 0
318
-
319
- while (true) {
320
- // Serialize headers as a plain object rather than a Headers instance.
321
- // React Native's XHR-based fetch silently drops headers (notably Content-Type)
322
- // when given a Headers instance, causing PGRST202 on parameter-less RPC calls.
323
- // See supabase/supabase-js#1562 and facebook/react-native#33933.
324
- // All sibling packages (auth-js, storage-js, functions-js) already pass plain objects.
325
- const headers: Record<string, string> = {}
326
- this.headers.forEach((value, key) => {
327
- headers[key] = value
328
- })
329
- if (attemptCount > 0) {
330
- headers['X-Retry-Count'] = String(attemptCount)
331
- }
332
-
333
- // Only wrap the fetch call itself — processResponse errors must never trigger retries
334
- let res: Response
335
- try {
336
- res = await _fetch(this.url.toString(), {
337
- method: this.method,
338
- headers,
339
- body: JSON.stringify(this.body, (_, value) =>
340
- typeof value === 'bigint' ? value.toString() : value
341
- ),
342
- signal: this.signal,
343
- })
344
- // JS allows throwing any value, and serverless or realm-crossing fetch
345
- // implementations can reject with non-Error objects. `instanceof Error`
346
- // is too narrow here; narrow at the use site with optional chaining.
347
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
348
- } catch (fetchError: any) {
349
- // Never retry aborted requests
350
- if (fetchError?.name === 'AbortError' || fetchError?.code === 'ABORT_ERR') {
351
- throw fetchError
352
- }
353
-
354
- // Don't retry network errors for non-idempotent methods
355
- if (!RETRYABLE_METHODS.includes(this.method as (typeof RETRYABLE_METHODS)[number])) {
356
- throw fetchError
357
- }
358
-
359
- // Check if we should retry network errors
360
- if (this.retryEnabled && attemptCount < DEFAULT_MAX_RETRIES) {
361
- const delay = getRetryDelay(attemptCount)
362
- attemptCount++
363
- await sleep(delay, this.signal)
364
- continue
365
- }
366
-
367
- // Exhausted retries or retries disabled, throw the last error
368
- throw fetchError
369
- }
370
-
371
- // Check if we should retry this HTTP response
372
- if (shouldRetry(this.method, res.status, attemptCount, this.retryEnabled)) {
373
- const retryAfterHeader = res.headers?.get('Retry-After') ?? null
374
- const delay =
375
- retryAfterHeader !== null
376
- ? Math.max(0, parseInt(retryAfterHeader, 10) || 0) * 1000
377
- : getRetryDelay(attemptCount)
378
- await res.text()
379
- attemptCount++
380
- await sleep(delay, this.signal)
381
- continue
382
- }
262
+ // Serialize headers as a plain object rather than a Headers instance.
263
+ // React Native's XHR-based fetch silently drops headers (notably Content-Type)
264
+ // when given a Headers instance, causing PGRST202 on parameter-less RPC calls.
265
+ // See supabase/supabase-js#1562 and facebook/react-native#33933.
266
+ // All sibling packages (auth-js, storage-js, functions-js) already pass plain objects.
267
+ const headers: Record<string, string> = {}
268
+ this.headers.forEach((value, key) => {
269
+ headers[key] = value
270
+ })
383
271
 
384
- return await this.processResponse(res)
385
- }
272
+ // Only the fetch itself is retried — processResponse errors must never trigger retries
273
+ const res = await fetchWithRetry(
274
+ _fetch,
275
+ this.url.toString(),
276
+ {
277
+ method: this.method,
278
+ headers,
279
+ body: JSON.stringify(this.body, (_, value) =>
280
+ typeof value === 'bigint' ? value.toString() : value
281
+ ),
282
+ signal: this.signal,
283
+ },
284
+ this.retryEnabled
285
+ )
286
+
287
+ return await this.processResponse(res)
386
288
  }
387
289
 
388
290
  let res = executeWithRetry()
@@ -2,6 +2,62 @@ import PostgrestQueryBuilder from './PostgrestQueryBuilder'
2
2
  import PostgrestFilterBuilder from './PostgrestFilterBuilder'
3
3
  import { Fetch, GenericSchema, ClientServerOptions } from './types/common/common'
4
4
  import { GetRpcFunctionFilterBuilderByArgs } from './types/common/rpc'
5
+ import PostgrestError from './PostgrestError'
6
+ import { fetchWithRetry } from './fetchWithRetry'
7
+ import {
8
+ PostgrestOpenApiSpec,
9
+ PostgrestResponseFailure,
10
+ PostgrestSingleResponse,
11
+ } from './types/types'
12
+
13
+ /**
14
+ * Build the error for a failed OpenAPI request. PostgREST answers with a JSON
15
+ * error object when it produced the failure itself; proxies and disabled
16
+ * OpenAPI output answer with plain text or an empty body, in which case the
17
+ * body (or, failing that, the status text) becomes the message.
18
+ */
19
+ function toOpenApiError(body: string, statusText: string): PostgrestError {
20
+ try {
21
+ const parsed = JSON.parse(body)
22
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
23
+ return new PostgrestError({
24
+ message: String(parsed.message ?? body),
25
+ details: parsed.details ?? '',
26
+ hint: parsed.hint ?? '',
27
+ code: parsed.code ?? '',
28
+ })
29
+ }
30
+ } catch {
31
+ // Not JSON; fall through to the plain-text message.
32
+ }
33
+ return new PostgrestError({ message: body || statusText, details: '', hint: '', code: '' })
34
+ }
35
+
36
+ /**
37
+ * Build the response for a request that yielded no readable body: the fetch
38
+ * itself rejected (no status is known, so it is 0), or the body stream failed
39
+ * while being read (the response status is preserved).
40
+ */
41
+ function toTransportFailure(
42
+ cause: unknown,
43
+ status: number,
44
+ statusText: string
45
+ ): PostgrestResponseFailure {
46
+ const err = cause as { name?: string; message?: string } | null | undefined
47
+ return {
48
+ success: false,
49
+ error: new PostgrestError({
50
+ message: `${err?.name ?? 'FetchError'}: ${err?.message}`,
51
+ details: '',
52
+ hint: '',
53
+ code: '',
54
+ }),
55
+ data: null,
56
+ count: null,
57
+ status,
58
+ statusText,
59
+ }
60
+ }
5
61
 
6
62
  /**
7
63
  * PostgREST client.
@@ -203,6 +259,87 @@ export default class PostgrestClient<
203
259
  })
204
260
  }
205
261
 
262
+ /**
263
+ * Fetch the OpenAPI description PostgREST publishes for this client's schema.
264
+ *
265
+ * The document lists only the tables, views and functions the caller's role
266
+ * holds privileges on; PostgREST applies that filtering server-side. The
267
+ * schema is the one this client was created with, so call `.schema()` first
268
+ * to describe a different one. Transient failures are retried according to
269
+ * the client's `retry` option, like any other idempotent request.
270
+ *
271
+ * @example
272
+ * ```ts
273
+ * const { data, error } = await supabase.getOpenApiSpec()
274
+ * ```
275
+ *
276
+ * @example Describe a schema other than the client default
277
+ * ```ts
278
+ * const { data, error } = await supabase.schema('billing').getOpenApiSpec()
279
+ * ```
280
+ *
281
+ * @category Database
282
+ */
283
+ async getOpenApiSpec(): Promise<PostgrestSingleResponse<PostgrestOpenApiSpec>> {
284
+ const headers = new Headers(this.headers)
285
+ headers.set('Accept', 'application/openapi+json')
286
+ if (this.schemaName) {
287
+ headers.set('Accept-Profile', this.schemaName)
288
+ }
289
+
290
+ // Headers reach the fetch implementation as a plain object, as in
291
+ // PostgrestBuilder: React Native's XHR-based fetch drops headers that are
292
+ // supplied as a Headers instance.
293
+ const requestHeaders: Record<string, string> = {}
294
+ headers.forEach((value, key) => {
295
+ requestHeaders[key] = value
296
+ })
297
+
298
+ const fetchImpl = this.fetch ?? globalThis.fetch
299
+ let res: Response
300
+ try {
301
+ res = await fetchWithRetry(
302
+ fetchImpl,
303
+ `${this.url}/`,
304
+ { method: 'GET', headers: requestHeaders },
305
+ this.retry ?? true
306
+ )
307
+ } catch (fetchError) {
308
+ return toTransportFailure(fetchError, 0, '')
309
+ }
310
+
311
+ let body: string
312
+ try {
313
+ body = await res.text()
314
+ } catch (readError) {
315
+ return toTransportFailure(readError, res.status, res.statusText)
316
+ }
317
+
318
+ if (res.ok) {
319
+ try {
320
+ return {
321
+ success: true,
322
+ error: null,
323
+ data: JSON.parse(body) as PostgrestOpenApiSpec,
324
+ count: null,
325
+ status: res.status,
326
+ statusText: res.statusText,
327
+ }
328
+ } catch {
329
+ // A 2xx status does not guarantee a JSON body; report it like a failure.
330
+ }
331
+ }
332
+
333
+ return {
334
+ success: false,
335
+ error: toOpenApiError(body, res.statusText),
336
+ data: null,
337
+ count: null,
338
+ status: res.status,
339
+ statusText: res.statusText,
340
+ }
341
+ }
342
+
206
343
  /**
207
344
  * Perform a function call.
208
345
  *
@@ -0,0 +1,141 @@
1
+ import {
2
+ DEFAULT_MAX_RETRIES,
3
+ Fetch,
4
+ getRetryDelay,
5
+ RETRYABLE_METHODS,
6
+ RETRYABLE_STATUS_CODES,
7
+ } from './types/common/common'
8
+
9
+ /**
10
+ * Sleep for a given number of milliseconds.
11
+ * If an AbortSignal is provided, the sleep resolves early when the signal is aborted.
12
+ */
13
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
14
+ return new Promise((resolve) => {
15
+ if (signal?.aborted) {
16
+ resolve()
17
+ return
18
+ }
19
+ const id = setTimeout(() => {
20
+ signal?.removeEventListener('abort', onAbort)
21
+ resolve()
22
+ }, ms)
23
+ function onAbort() {
24
+ clearTimeout(id)
25
+ resolve()
26
+ }
27
+ signal?.addEventListener('abort', onAbort)
28
+ })
29
+ }
30
+
31
+ /**
32
+ * Check if a request should be retried based on method and status code.
33
+ */
34
+ function shouldRetry(
35
+ method: string,
36
+ status: number,
37
+ attemptCount: number,
38
+ retryEnabled: boolean
39
+ ): boolean {
40
+ // Don't retry if retries are disabled or we've exhausted attempts
41
+ if (!retryEnabled || attemptCount >= DEFAULT_MAX_RETRIES) {
42
+ return false
43
+ }
44
+
45
+ // Only retry idempotent methods (GET, HEAD, OPTIONS)
46
+ if (!RETRYABLE_METHODS.includes(method as (typeof RETRYABLE_METHODS)[number])) {
47
+ return false
48
+ }
49
+
50
+ // Only retry on specific status codes (520 - Cloudflare errors)
51
+ if (!RETRYABLE_STATUS_CODES.includes(status as (typeof RETRYABLE_STATUS_CODES)[number])) {
52
+ return false
53
+ }
54
+
55
+ return true
56
+ }
57
+
58
+ export interface RetryableRequest {
59
+ method: string
60
+ /** Plain object, never a Headers instance: React Native's XHR-based fetch drops the latter. */
61
+ headers: Record<string, string>
62
+ body?: string
63
+ signal?: AbortSignal
64
+ }
65
+
66
+ /**
67
+ * Perform a request with the retry policy shared by every PostgREST call.
68
+ *
69
+ * Idempotent methods (GET, HEAD, OPTIONS) are retried up to
70
+ * `DEFAULT_MAX_RETRIES` times when the fetch rejects or the server answers
71
+ * with a retryable status (503, 520). The wait honours the `Retry-After`
72
+ * header when present and backs off exponentially otherwise. Retried
73
+ * attempts carry an `X-Retry-Count` header. Aborted requests and
74
+ * non-idempotent methods are never retried: their rejection propagates
75
+ * unchanged. A response body is drained before its request is retried.
76
+ */
77
+ export async function fetchWithRetry(
78
+ fetchImpl: Fetch,
79
+ url: string,
80
+ request: RetryableRequest,
81
+ retryEnabled: boolean
82
+ ): Promise<Response> {
83
+ let attemptCount = 0
84
+
85
+ while (true) {
86
+ const headers: Record<string, string> = { ...request.headers }
87
+ if (attemptCount > 0) {
88
+ headers['X-Retry-Count'] = String(attemptCount)
89
+ }
90
+
91
+ let res: Response
92
+ try {
93
+ res = await fetchImpl(url, {
94
+ method: request.method,
95
+ headers,
96
+ body: request.body,
97
+ signal: request.signal,
98
+ })
99
+ // JS allows throwing any value, and serverless or realm-crossing fetch
100
+ // implementations can reject with non-Error objects. `instanceof Error`
101
+ // is too narrow here; narrow at the use site with optional chaining.
102
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
103
+ } catch (fetchError: any) {
104
+ // Never retry aborted requests
105
+ if (fetchError?.name === 'AbortError' || fetchError?.code === 'ABORT_ERR') {
106
+ throw fetchError
107
+ }
108
+
109
+ // Don't retry network errors for non-idempotent methods
110
+ if (!RETRYABLE_METHODS.includes(request.method as (typeof RETRYABLE_METHODS)[number])) {
111
+ throw fetchError
112
+ }
113
+
114
+ // Check if we should retry network errors
115
+ if (retryEnabled && attemptCount < DEFAULT_MAX_RETRIES) {
116
+ const delay = getRetryDelay(attemptCount)
117
+ attemptCount++
118
+ await sleep(delay, request.signal)
119
+ continue
120
+ }
121
+
122
+ // Exhausted retries or retries disabled, throw the last error
123
+ throw fetchError
124
+ }
125
+
126
+ // Check if we should retry this HTTP response
127
+ if (shouldRetry(request.method, res.status, attemptCount, retryEnabled)) {
128
+ const retryAfterHeader = res.headers?.get('Retry-After') ?? null
129
+ const delay =
130
+ retryAfterHeader !== null
131
+ ? Math.max(0, parseInt(retryAfterHeader, 10) || 0) * 1000
132
+ : getRetryDelay(attemptCount)
133
+ await res.text()
134
+ attemptCount++
135
+ await sleep(delay, request.signal)
136
+ continue
137
+ }
138
+
139
+ return res
140
+ }
141
+ }
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ export type {
27
27
  PostgrestResponseSuccess,
28
28
  PostgrestSingleResponse,
29
29
  PostgrestMaybeSingleResponse,
30
+ PostgrestOpenApiSpec,
30
31
  } from './types/types'
31
32
  export type { ClientServerOptions as PostgrestClientOptions } from './types/common/common'
32
33
  // https://github.com/supabase/postgrest-js/issues/551
@@ -40,6 +40,28 @@ export type PostgrestSingleResponse<T> = PostgrestResponseSuccess<T> | Postgrest
40
40
  export type PostgrestMaybeSingleResponse<T> = PostgrestSingleResponse<T | null>
41
41
  export type PostgrestResponse<T> = PostgrestSingleResponse<T[]>
42
42
 
43
+ /**
44
+ * OpenAPI description served by PostgREST at the REST root (`GET /`).
45
+ *
46
+ * PostgREST emits OpenAPI 2.0 (Swagger), so the version lives in `swagger`.
47
+ * OpenAPI 2.0 requires only `swagger`, `info` and `paths`; a PostgREST
48
+ * `db-root-spec` override may leave out any other field. Only the top level
49
+ * is typed here; the contents of `paths`, `definitions` and `parameters`
50
+ * follow the OpenAPI 2.0 specification and vary with the PostgREST version.
51
+ *
52
+ * {@link https://docs.postgrest.org/en/stable/references/api/openapi.html}
53
+ */
54
+ export interface PostgrestOpenApiSpec {
55
+ swagger: string
56
+ info: Record<string, unknown>
57
+ host?: string
58
+ basePath?: string
59
+ paths: Record<string, Record<string, unknown>>
60
+ definitions?: Record<string, Record<string, unknown>>
61
+ parameters?: Record<string, Record<string, unknown>>
62
+ [key: string]: unknown
63
+ }
64
+
43
65
  export type DatabaseWithOptions<Database, Options extends ClientServerOptions> = {
44
66
  db: Database
45
67
  options: Options
package/src/version.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  // - Debugging and support (identifying which version is running)
5
5
  // - Telemetry and logging (version reporting in errors/analytics)
6
6
  // - Ensuring build artifacts match the published package version
7
- export const version = '2.114.0'
7
+ export const version = '2.115.0'