Skip to content

Commit d5dbca4

Browse files
committed
feat(storage): expose purge cache for entire bucket
1 parent d9043a4 commit d5dbca4

2 files changed

Lines changed: 124 additions & 2 deletions

File tree

packages/core/storage-js/src/packages/StorageBucketApi.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { DEFAULT_HEADERS } from '../lib/constants'
22
import { StorageError } from '../lib/common/errors'
33
import { Fetch, get, post, put, remove } from '../lib/common/fetch'
44
import BaseApiClient from '../lib/common/BaseApiClient'
5-
import { Bucket, BucketType, ListBucketOptions } from '../lib/types'
5+
import { Bucket, BucketType, FetchParameters, ListBucketOptions } from '../lib/types'
66
import { StorageClientOptions } from '../StorageClient'
77

88
export default class StorageBucketApi extends BaseApiClient<StorageError> {
@@ -390,6 +390,60 @@ export default class StorageBucketApi extends BaseApiClient<StorageError> {
390390
})
391391
}
392392

393+
/**
394+
* Purges the CDN cache for an entire bucket.
395+
*
396+
* Maps to `DELETE /cdn/{bucket}/{path}` on the Storage API. The server
397+
* issues a CDN invalidation for the object and returns `{ message: 'success' }`.
398+
*
399+
* **Requires the `service_role` key.** The underlying endpoint enforces
400+
* `service_role` JWT — calls made with the anon key or a user JWT will be
401+
* rejected by the server.
402+
*
403+
* **Hosted CDN feature.** On self-hosted Supabase, the Storage service must
404+
* have `CDN_PURGE_ENDPOINT_URL` configured and the `purgeCache` tenant
405+
* feature enabled, otherwise the server returns an error.
406+
*
407+
* Operates on a single object path. There is no wildcard or recursion: pass
408+
* the exact path of the object you want invalidated.
409+
*
410+
* @category Storage
411+
* @subcategory File Buckets
412+
* @param path The path (relative to the bucket) of the object to purge, e.g. `folder/avatar.png`.
413+
* @param parameters Optional fetch parameters such as an `AbortController` signal.
414+
* @returns Promise with `{ data: { message }, error: null }` on success or `{ data: null, error }` on failure.
415+
*
416+
* @example Purge cache for an entire bucket
417+
* ```js
418+
* const { data, error } = await supabase
419+
* .storage
420+
* .purgeBucketCache('avatars')
421+
* ```
422+
*/
423+
async purgeBucketCache(
424+
id: string,
425+
parameters?: FetchParameters
426+
): Promise<
427+
| {
428+
data: { message: string }
429+
error: null
430+
}
431+
| {
432+
data: null
433+
error: StorageError
434+
}
435+
> {
436+
return this.handleOperation(async () => {
437+
return await remove(
438+
this.fetch,
439+
`${this.url}/cdn/${id}`,
440+
{},
441+
{ headers: this.headers },
442+
parameters
443+
)
444+
})
445+
}
446+
393447
private listBucketOptionsToQueryString(options?: ListBucketOptions): string {
394448
const params: Record<string, string> = {}
395449
if (options) {

packages/core/storage-js/test/storageBucketApi.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { StorageClient } from '../src/index'
2-
import { StorageUnknownError } from '../src/lib/common/errors'
2+
import { StorageApiError, StorageUnknownError } from '../src/lib/common/errors'
33

44
// Create a simple Response implementation for testing
55
class MockResponse {
@@ -539,4 +539,72 @@ describe('Bucket API Error Handling', () => {
539539
mockFn.mockRestore()
540540
})
541541
})
542+
543+
describe('purgeBucketCache', () => {
544+
const PURGE_URL = 'http://localhost:8000/storage/v1'
545+
const BUCKET = 'avatars'
546+
547+
afterEach(() => {
548+
jest.restoreAllMocks()
549+
})
550+
551+
it('issues DELETE to /cdn/{bucket} and returns the server message', async () => {
552+
const fetchMock = jest.fn().mockResolvedValue(
553+
new Response(JSON.stringify({ message: 'success' }), {
554+
status: 200,
555+
headers: { 'Content-Type': 'application/json' },
556+
})
557+
)
558+
global.fetch = fetchMock
559+
560+
const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' })
561+
const { data, error } = await client.purgeBucketCache(BUCKET)
562+
563+
expect(error).toBeNull()
564+
expect(data?.message).toBe('success')
565+
expect(fetchMock).toHaveBeenCalledWith(
566+
`${PURGE_URL}/cdn/${BUCKET}`,
567+
expect.objectContaining({ method: 'DELETE' })
568+
)
569+
})
570+
571+
it('surfaces server errors via StorageApiError', async () => {
572+
const fetchMock = jest
573+
.fn()
574+
.mockResolvedValue(
575+
new Response(
576+
JSON.stringify({ statusCode: '404', error: 'Not Found', message: 'Object not found' }),
577+
{ status: 404, headers: { 'Content-Type': 'application/json' } }
578+
)
579+
)
580+
global.fetch = fetchMock
581+
582+
const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' })
583+
const { data, error } = await client.purgeBucketCache(BUCKET)
584+
585+
expect(data).toBeNull()
586+
expect(error).toBeInstanceOf(StorageApiError)
587+
expect(error?.message).toBe('Object not found')
588+
})
589+
590+
it('forwards the AbortController signal to fetch', async () => {
591+
const fetchMock = jest.fn().mockResolvedValue(
592+
new Response(JSON.stringify({ message: 'success' }), {
593+
status: 200,
594+
headers: { 'Content-Type': 'application/json' },
595+
})
596+
)
597+
global.fetch = fetchMock
598+
599+
const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' })
600+
const controller = new AbortController()
601+
const { error } = await client.purgeBucketCache(BUCKET, { signal: controller.signal })
602+
603+
expect(error).toBeNull()
604+
expect(fetchMock).toHaveBeenCalledWith(
605+
`${PURGE_URL}/cdn/${BUCKET}`,
606+
expect.objectContaining({ method: 'DELETE', signal: controller.signal })
607+
)
608+
})
609+
})
542610
})

0 commit comments

Comments
 (0)