Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/core/storage-js/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ export interface ListBucketOptions {
search?: string
}

export interface PurgeCacheOptions {
/**
* If true, purges only the transformations (resized/formatted variants) for the object or bucket,
* leaving the original cached file intact. If omitted, purges all cached versions.
*/
transformations?: true
}

/**
* Represents an Analytics Bucket using Apache Iceberg table format.
* Analytics buckets are optimized for analytical queries and data processing.
Expand Down
75 changes: 74 additions & 1 deletion packages/core/storage-js/src/packages/StorageBucketApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { DEFAULT_HEADERS } from '../lib/constants'
import { StorageError } from '../lib/common/errors'
import { Fetch, get, post, put, remove } from '../lib/common/fetch'
import BaseApiClient from '../lib/common/BaseApiClient'
import { Bucket, BucketType, ListBucketOptions } from '../lib/types'
import {
Bucket,
BucketType,
FetchParameters,
ListBucketOptions,
PurgeCacheOptions,
} from '../lib/types'
import { StorageClientOptions } from '../StorageClient'

export default class StorageBucketApi extends BaseApiClient<StorageError> {
Expand Down Expand Up @@ -390,6 +396,73 @@ export default class StorageBucketApi extends BaseApiClient<StorageError> {
})
}

/**
* Purges the CDN cache for an entire bucket.
*
* Maps to `DELETE /cdn/{bucket}` on the Storage API. The server
* issues a CDN invalidation for the bucket and returns `{ message: 'success' }`.
*
* **Requires the `service_role` key.** The underlying endpoint enforces
* `service_role` JWT — calls made with the anon key or a user JWT will be
* rejected by the server.
*
* **Hosted CDN feature.** On self-hosted Supabase, the Storage service must
* have `CDN_PURGE_ENDPOINT_URL` configured and the `purgeCache` tenant
* feature enabled, otherwise the server returns an error.
*
* @category Storage
* @subcategory File Buckets
* @param id The unique identifier of the bucket you would like to purge from cache.
* @param options Optional purge cache options.
* @param options.transformations If true, purges only transformations (resized/formatted variants), leaving original cached files intact.
* @param parameters Optional fetch parameters such as an `AbortController` signal.
* @returns Promise with `{ data: { message }, error: null }` on success or `{ data: null, error }` on failure.
*
* @example Purge cache for an entire bucket
* ```js
* const { data, error } = await supabase
* .storage
* .purgeBucketCache('avatars')
* ```
*
* @example Purge only transformations for an entire bucket
* ```js
* const { data, error } = await supabase
* .storage
* .purgeBucketCache('avatars', { transformations: true })
* ```
*/
async purgeBucketCache(
id: string,
options?: PurgeCacheOptions,
parameters?: FetchParameters
): Promise<
| {
data: { message: string }
error: null
}
| {
data: null
error: StorageError
}
> {
return this.handleOperation(async () => {
const query = new URLSearchParams()
if (options?.transformations) {
query.set('transformations', 'true')
}
const queryString = query.toString()

return await remove(
this.fetch,
`${this.url}/cdn/${id}${queryString ? `?${queryString}` : ''}`,
{},
{ headers: this.headers },
parameters
)
})
}

private listBucketOptionsToQueryString(options?: ListBucketOptions): string {
const params: Record<string, string> = {}
if (options) {
Expand Down
74 changes: 74 additions & 0 deletions packages/core/storage-js/src/packages/StorageFileApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
Camelize,
SearchV2Options,
SearchV2Result,
PurgeCacheOptions,
} from '../lib/types'
import BlobDownloadBuilder from './BlobDownloadBuilder'

Expand Down Expand Up @@ -1160,6 +1161,79 @@ export default class StorageFileApi extends BaseApiClient<StorageError> {
})
}

/**
* Purges the CDN cache for a single object in this bucket.
*
* Maps to `DELETE /cdn/{bucket}/{path}` on the Storage API. The server
* issues a CDN invalidation for the object and returns `{ message: 'success' }`.
*
* **Requires the `service_role` key.** The underlying endpoint enforces
* `service_role` JWT — calls made with the anon key or a user JWT will be
* rejected by the server.
*
* **Hosted CDN feature.** On self-hosted Supabase, the Storage service must
* have `CDN_PURGE_ENDPOINT_URL` configured and the `purgeCache` tenant
* feature enabled, otherwise the server returns an error.
*
* Operates on a single object path. There is no wildcard or recursion: pass
* the exact path of the object you want invalidated.
*
* @category Storage
* @subcategory File Buckets
* @param path The path (relative to the bucket) of the object to purge, e.g. `folder/avatar.png`.
* @param options Optional purge cache options.
* @param options.transformations If true, purges only transformations (resized/formatted variants), leaving the original cached file intact.
* @param parameters Optional fetch parameters such as an `AbortController` signal.
* @returns Promise with `{ data: { message }, error: null }` on success or `{ data: null, error }` on failure.
*
* @example Purge a single cached object
* ```js
* const { data, error } = await supabase
* .storage
* .from('avatars')
* .purgeCache('folder/avatar1.png')
* ```
*
* @example Purge only transformations for a single object
* ```js
* const { data, error } = await supabase
* .storage
* .from('avatars')
* .purgeCache('folder/avatar1.png', { transformations: true })
* ```
*/
async purgeCache(
path: string,
options?: PurgeCacheOptions,
parameters?: FetchParameters
): Promise<
| {
data: { message: string }
error: null
}
| {
data: null
error: StorageError
}
> {
return this.handleOperation(async () => {
const _path = this._getFinalPath(path)
const query = new URLSearchParams()
if (options?.transformations) {
query.set('transformations', 'true')
}
const queryString = query.toString()

return await remove(
this.fetch,
`${this.url}/cdn/${_path}${queryString ? `?${queryString}` : ''}`,
{},
{ headers: this.headers },
parameters
)
})
}

/**
* Get file metadata
* @param id the file id to retrieve metadata
Expand Down
114 changes: 113 additions & 1 deletion packages/core/storage-js/test/storageBucketApi.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { StorageClient } from '../src/index'
import { StorageUnknownError } from '../src/lib/common/errors'
import { StorageApiError, StorageUnknownError } from '../src/lib/common/errors'

// Create a simple Response implementation for testing
class MockResponse {
Expand Down Expand Up @@ -539,4 +539,116 @@ describe('Bucket API Error Handling', () => {
mockFn.mockRestore()
})
})

describe('purgeBucketCache', () => {
const PURGE_URL = 'http://localhost:8000/storage/v1'
const BUCKET = 'avatars'

afterEach(() => {
jest.restoreAllMocks()
})

it('issues DELETE to /cdn/{bucket} and returns the server message', async () => {
const fetchMock = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ message: 'success' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
global.fetch = fetchMock

const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' })
const { data, error } = await client.purgeBucketCache(BUCKET)

expect(error).toBeNull()
expect(data?.message).toBe('success')
expect(fetchMock).toHaveBeenCalledWith(
`${PURGE_URL}/cdn/${BUCKET}`,
expect.objectContaining({ method: 'DELETE' })
)
})

it('surfaces server errors via StorageApiError', async () => {
const fetchMock = jest.fn().mockResolvedValue(
new Response(
JSON.stringify({
statusCode: '403',
error: 'Forbidden',
message: 'Feature not enabled',
}),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
)
)
global.fetch = fetchMock

const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' })
const { data, error } = await client.purgeBucketCache(BUCKET)

expect(data).toBeNull()
expect(error).toBeInstanceOf(StorageApiError)
expect(error?.message).toBe('Feature not enabled')
})

it('appends transformations query param when transformations option is true', async () => {
const fetchMock = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ message: 'success' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
global.fetch = fetchMock

const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' })
const { data, error } = await client.purgeBucketCache(BUCKET, { transformations: true })

expect(error).toBeNull()
expect(data?.message).toBe('success')
expect(fetchMock).toHaveBeenCalledWith(
`${PURGE_URL}/cdn/${BUCKET}?transformations=true`,
expect.objectContaining({ method: 'DELETE' })
)
})

it('omits transformations query param when transformations option is not provided', async () => {
const fetchMock = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ message: 'success' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
global.fetch = fetchMock

const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' })
const { data, error } = await client.purgeBucketCache(BUCKET)

expect(error).toBeNull()
expect(data?.message).toBe('success')
expect(fetchMock).toHaveBeenCalledWith(
`${PURGE_URL}/cdn/${BUCKET}`,
expect.objectContaining({ method: 'DELETE' })
)
})

it('forwards the AbortController signal to fetch', async () => {
const fetchMock = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ message: 'success' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
global.fetch = fetchMock

const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' })
const controller = new AbortController()
const { error } = await client.purgeBucketCache(BUCKET, undefined, {
signal: controller.signal,
})

expect(error).toBeNull()
expect(fetchMock).toHaveBeenCalledWith(
`${PURGE_URL}/cdn/${BUCKET}`,
expect.objectContaining({ method: 'DELETE', signal: controller.signal })
)
})
})
})
Loading
Loading