From 33a9d69e87bd2efd83859ad616bd2d95c29764ef Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Thu, 4 Jun 2026 15:08:46 +0300 Subject: [PATCH 1/5] feat(storage): expose purgeCache for single objects Co-authored-by: Rodrigo Mansueli --- .../core/storage-js/migrations/purge-cache.md | 60 ++++++++++++++++ .../storage-js/src/packages/StorageFileApi.ts | 56 +++++++++++++++ .../storage-js/test/storageFileApi.test.ts | 69 +++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 packages/core/storage-js/migrations/purge-cache.md diff --git a/packages/core/storage-js/migrations/purge-cache.md b/packages/core/storage-js/migrations/purge-cache.md new file mode 100644 index 0000000000..332a6ff9d1 --- /dev/null +++ b/packages/core/storage-js/migrations/purge-cache.md @@ -0,0 +1,60 @@ +# Purge CDN cache for individual objects + +**Since:** v2.X.Y (update at release time) +**Action required by:** none — additive API + +`@supabase/storage-js` now exposes `storage.from(bucket).purgeCache(path)`, mapping to the Storage API's `DELETE /cdn/{bucket}/{path}` endpoint. Calling it invalidates the Supabase CDN cache for the named object. + +## What changed + +- **New method `purgeCache(path, parameters?)`** on the file-bucket API. Returns `{ data: { message }, error: null }` on success or `{ data: null, error }` on failure. Accepts an optional `parameters` object for `AbortController` signals and other fetch parameters, matching the shape used by `download`. +- The method operates on a **single object path**. No wildcards, no recursion, no prefix purging. Pass the exact path of the object whose cache you want to invalidate. + +## Who is affected + +Only callers who want to issue CDN invalidations. The method is additive — existing code is unchanged. + +## Requirements + +This API is a thin wrapper over the Storage server's `/cdn/{bucket}/*` route. Two operational requirements live entirely on the server side: + +- **`service_role` key required.** The Storage server enforces a `service_role` JWT on the `/cdn` route. Calls made with the anon key or a user JWT will be rejected by the server. Use `purgeCache` from trusted backends only — do not call it from a browser with the anon key. +- **Hosted CDN feature.** On hosted Supabase the endpoint is gated behind a tenant feature flag (`purgeCache`). On self-hosted deployments the Storage service must have `CDN_PURGE_ENDPOINT_URL` configured and the `purgeCache` tenant feature enabled, otherwise the server returns an error. + +## Usage + +```ts +import { createClient } from '@supabase/supabase-js' + +// service_role key — must not be shipped to the browser +const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY) + +const { data, error } = await supabase.storage.from('avatars').purgeCache('folder/avatar1.png') + +if (error) { + // Server-side errors (404 not found, 403 wrong role, tenant feature disabled, etc.) + // surface here as StorageApiError. Network failures surface as StorageUnknownError. +} +``` + +### Cancelling an in-flight purge + +```ts +const controller = new AbortController() + +const { error } = await supabase.storage + .from('avatars') + .purgeCache('folder/avatar1.png', { signal: controller.signal }) + +// controller.abort() at any point cancels the request. +``` + +## What is not included + +There is intentionally no `purgeCacheByPrefix` or "purge folder" variant in this release. The Storage server has no native prefix-purge endpoint, and a client-side list-then-purge-each loop would be silently non-recursive (the list endpoint is not recursive), unpaginated past 1000 objects, and sequential per object. If you need bulk invalidation, purge the specific objects you care about, or open a request against the Storage server. + +## Reference + +- Storage server route: `src/http/routes/cdn/purgeCache.ts` in [supabase/storage](https://github.com/supabase/storage). The route is `fastify.delete('/:bucketName/*')` mounted under `/cdn`. +- Auth gate: `src/http/routes/cdn/index.ts` registers the route with `enforceJwtRoles: [dbServiceRole]` and `requireTenantFeature('purgeCache')`. +- CDN delegation: `src/storage/cdn/cdn-cache-manager.ts` forwards to `CDN_PURGE_ENDPOINT_URL` (or errors out if unset). diff --git a/packages/core/storage-js/src/packages/StorageFileApi.ts b/packages/core/storage-js/src/packages/StorageFileApi.ts index 6227bc0995..6e8cb96b2d 100644 --- a/packages/core/storage-js/src/packages/StorageFileApi.ts +++ b/packages/core/storage-js/src/packages/StorageFileApi.ts @@ -1160,6 +1160,62 @@ export default class StorageFileApi extends BaseApiClient { }) } + /** + * 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 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') + * ``` + */ + async purgeCache( + path: string, + parameters?: FetchParameters + ): Promise< + | { + data: { message: string } + error: null + } + | { + data: null + error: StorageError + } + > { + return this.handleOperation(async () => { + const _path = this._getFinalPath(path) + return await remove( + this.fetch, + `${this.url}/cdn/${_path}`, + {}, + { headers: this.headers }, + parameters + ) + }) + } + /** * Get file metadata * @param id the file id to retrieve metadata diff --git a/packages/core/storage-js/test/storageFileApi.test.ts b/packages/core/storage-js/test/storageFileApi.test.ts index fe79aa9d06..8e105f245b 100644 --- a/packages/core/storage-js/test/storageFileApi.test.ts +++ b/packages/core/storage-js/test/storageFileApi.test.ts @@ -1024,6 +1024,75 @@ describe('error handling', () => { }) }) +describe('purgeCache', () => { + const PURGE_URL = 'http://localhost:8000/storage/v1' + const BUCKET = 'avatars' + const PATH = 'folder/avatar.png' + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('issues DELETE to /cdn/{bucket}/{path} 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.from(BUCKET).purgeCache(PATH) + + expect(error).toBeNull() + expect(data?.message).toBe('success') + expect(fetchMock).toHaveBeenCalledWith( + `${PURGE_URL}/cdn/${BUCKET}/${PATH}`, + expect.objectContaining({ method: 'DELETE' }) + ) + }) + + it('surfaces server errors via StorageApiError', async () => { + const fetchMock = jest + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ statusCode: '404', error: 'Not Found', message: 'Object not found' }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ) + ) + global.fetch = fetchMock + + const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' }) + const { data, error } = await client.from(BUCKET).purgeCache(PATH) + + expect(data).toBeNull() + expect(error).toBeInstanceOf(StorageApiError) + expect(error?.message).toBe('Object not found') + }) + + 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.from(BUCKET).purgeCache(PATH, { signal: controller.signal }) + + expect(error).toBeNull() + expect(fetchMock).toHaveBeenCalledWith( + `${PURGE_URL}/cdn/${BUCKET}/${PATH}`, + expect.objectContaining({ method: 'DELETE', signal: controller.signal }) + ) + }) +}) + describe('StorageFileApi Edge Cases', () => { let storage: StorageClient From d9043a4c77ecf0060716b2d9420810295e0164be Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Thu, 4 Jun 2026 18:31:39 +0300 Subject: [PATCH 2/5] fix(storage): remove migration because not needed --- .../core/storage-js/migrations/purge-cache.md | 60 ------------------- 1 file changed, 60 deletions(-) delete mode 100644 packages/core/storage-js/migrations/purge-cache.md diff --git a/packages/core/storage-js/migrations/purge-cache.md b/packages/core/storage-js/migrations/purge-cache.md deleted file mode 100644 index 332a6ff9d1..0000000000 --- a/packages/core/storage-js/migrations/purge-cache.md +++ /dev/null @@ -1,60 +0,0 @@ -# Purge CDN cache for individual objects - -**Since:** v2.X.Y (update at release time) -**Action required by:** none — additive API - -`@supabase/storage-js` now exposes `storage.from(bucket).purgeCache(path)`, mapping to the Storage API's `DELETE /cdn/{bucket}/{path}` endpoint. Calling it invalidates the Supabase CDN cache for the named object. - -## What changed - -- **New method `purgeCache(path, parameters?)`** on the file-bucket API. Returns `{ data: { message }, error: null }` on success or `{ data: null, error }` on failure. Accepts an optional `parameters` object for `AbortController` signals and other fetch parameters, matching the shape used by `download`. -- The method operates on a **single object path**. No wildcards, no recursion, no prefix purging. Pass the exact path of the object whose cache you want to invalidate. - -## Who is affected - -Only callers who want to issue CDN invalidations. The method is additive — existing code is unchanged. - -## Requirements - -This API is a thin wrapper over the Storage server's `/cdn/{bucket}/*` route. Two operational requirements live entirely on the server side: - -- **`service_role` key required.** The Storage server enforces a `service_role` JWT on the `/cdn` route. Calls made with the anon key or a user JWT will be rejected by the server. Use `purgeCache` from trusted backends only — do not call it from a browser with the anon key. -- **Hosted CDN feature.** On hosted Supabase the endpoint is gated behind a tenant feature flag (`purgeCache`). On self-hosted deployments the Storage service must have `CDN_PURGE_ENDPOINT_URL` configured and the `purgeCache` tenant feature enabled, otherwise the server returns an error. - -## Usage - -```ts -import { createClient } from '@supabase/supabase-js' - -// service_role key — must not be shipped to the browser -const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY) - -const { data, error } = await supabase.storage.from('avatars').purgeCache('folder/avatar1.png') - -if (error) { - // Server-side errors (404 not found, 403 wrong role, tenant feature disabled, etc.) - // surface here as StorageApiError. Network failures surface as StorageUnknownError. -} -``` - -### Cancelling an in-flight purge - -```ts -const controller = new AbortController() - -const { error } = await supabase.storage - .from('avatars') - .purgeCache('folder/avatar1.png', { signal: controller.signal }) - -// controller.abort() at any point cancels the request. -``` - -## What is not included - -There is intentionally no `purgeCacheByPrefix` or "purge folder" variant in this release. The Storage server has no native prefix-purge endpoint, and a client-side list-then-purge-each loop would be silently non-recursive (the list endpoint is not recursive), unpaginated past 1000 objects, and sequential per object. If you need bulk invalidation, purge the specific objects you care about, or open a request against the Storage server. - -## Reference - -- Storage server route: `src/http/routes/cdn/purgeCache.ts` in [supabase/storage](https://github.com/supabase/storage). The route is `fastify.delete('/:bucketName/*')` mounted under `/cdn`. -- Auth gate: `src/http/routes/cdn/index.ts` registers the route with `enforceJwtRoles: [dbServiceRole]` and `requireTenantFeature('purgeCache')`. -- CDN delegation: `src/storage/cdn/cdn-cache-manager.ts` forwards to `CDN_PURGE_ENDPOINT_URL` (or errors out if unset). From d5dbca45272f05ca71e96323010a5e99b8a08c7c Mon Sep 17 00:00:00 2001 From: Lenny Date: Mon, 22 Jun 2026 17:16:47 -0400 Subject: [PATCH 3/5] feat(storage): expose purge cache for entire bucket --- .../src/packages/StorageBucketApi.ts | 56 ++++++++++++++- .../storage-js/test/storageBucketApi.test.ts | 70 ++++++++++++++++++- 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/packages/core/storage-js/src/packages/StorageBucketApi.ts b/packages/core/storage-js/src/packages/StorageBucketApi.ts index ddc10a6ae8..3c42791f20 100644 --- a/packages/core/storage-js/src/packages/StorageBucketApi.ts +++ b/packages/core/storage-js/src/packages/StorageBucketApi.ts @@ -2,7 +2,7 @@ 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 } from '../lib/types' import { StorageClientOptions } from '../StorageClient' export default class StorageBucketApi extends BaseApiClient { @@ -390,6 +390,60 @@ export default class StorageBucketApi extends BaseApiClient { }) } + /** + * Purges the CDN cache for an entire 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 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') + * ``` + */ + async purgeBucketCache( + id: string, + parameters?: FetchParameters + ): Promise< + | { + data: { message: string } + error: null + } + | { + data: null + error: StorageError + } + > { + return this.handleOperation(async () => { + return await remove( + this.fetch, + `${this.url}/cdn/${id}`, + {}, + { headers: this.headers }, + parameters + ) + }) + } + private listBucketOptionsToQueryString(options?: ListBucketOptions): string { const params: Record = {} if (options) { diff --git a/packages/core/storage-js/test/storageBucketApi.test.ts b/packages/core/storage-js/test/storageBucketApi.test.ts index 1090b1c9b0..d48e374541 100644 --- a/packages/core/storage-js/test/storageBucketApi.test.ts +++ b/packages/core/storage-js/test/storageBucketApi.test.ts @@ -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 { @@ -539,4 +539,72 @@ 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: '404', error: 'Not Found', message: 'Object not found' }), + { status: 404, 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('Object not found') + }) + + 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, { signal: controller.signal }) + + expect(error).toBeNull() + expect(fetchMock).toHaveBeenCalledWith( + `${PURGE_URL}/cdn/${BUCKET}`, + expect.objectContaining({ method: 'DELETE', signal: controller.signal }) + ) + }) + }) }) From 0445c492930504cbf073bbf1f5c3eef99bdab3b2 Mon Sep 17 00:00:00 2001 From: Lenny Date: Fri, 26 Jun 2026 13:22:05 -0400 Subject: [PATCH 4/5] fix(storage): improve docs and tests --- .../src/packages/StorageBucketApi.ts | 9 +++------ .../storage-js/test/storageBucketApi.test.ts | 18 ++++++++++-------- .../storage-js/test/storageFileApi.test.ts | 6 +++--- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/core/storage-js/src/packages/StorageBucketApi.ts b/packages/core/storage-js/src/packages/StorageBucketApi.ts index 3c42791f20..153400de96 100644 --- a/packages/core/storage-js/src/packages/StorageBucketApi.ts +++ b/packages/core/storage-js/src/packages/StorageBucketApi.ts @@ -393,8 +393,8 @@ export default class StorageBucketApi extends BaseApiClient { /** * Purges the CDN cache for an entire bucket. * - * Maps to `DELETE /cdn/{bucket}/{path}` on the Storage API. The server - * issues a CDN invalidation for the object and returns `{ message: 'success' }`. + * 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 @@ -404,12 +404,9 @@ export default class StorageBucketApi extends BaseApiClient { * 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 id The unique identifier of the bucket you would like to purge from cache. * @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. * diff --git a/packages/core/storage-js/test/storageBucketApi.test.ts b/packages/core/storage-js/test/storageBucketApi.test.ts index d48e374541..8ef1e18e64 100644 --- a/packages/core/storage-js/test/storageBucketApi.test.ts +++ b/packages/core/storage-js/test/storageBucketApi.test.ts @@ -569,14 +569,16 @@ describe('Bucket API Error Handling', () => { }) it('surfaces server errors via StorageApiError', async () => { - const fetchMock = jest - .fn() - .mockResolvedValue( - new Response( - JSON.stringify({ statusCode: '404', error: 'Not Found', message: 'Object not found' }), - { status: 404, headers: { 'Content-Type': 'application/json' } } - ) + 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' }) @@ -584,7 +586,7 @@ describe('Bucket API Error Handling', () => { expect(data).toBeNull() expect(error).toBeInstanceOf(StorageApiError) - expect(error?.message).toBe('Object not found') + expect(error?.message).toBe('Feature not enabled') }) it('forwards the AbortController signal to fetch', async () => { diff --git a/packages/core/storage-js/test/storageFileApi.test.ts b/packages/core/storage-js/test/storageFileApi.test.ts index 8e105f245b..24d31191b9 100644 --- a/packages/core/storage-js/test/storageFileApi.test.ts +++ b/packages/core/storage-js/test/storageFileApi.test.ts @@ -1058,8 +1058,8 @@ describe('purgeCache', () => { .fn() .mockResolvedValue( new Response( - JSON.stringify({ statusCode: '404', error: 'Not Found', message: 'Object not found' }), - { status: 404, headers: { 'Content-Type': 'application/json' } } + JSON.stringify({ statusCode: '403', error: 'Forbidden', message: 'Feature not enabled' }), + { status: 403, headers: { 'Content-Type': 'application/json' } } ) ) global.fetch = fetchMock @@ -1069,7 +1069,7 @@ describe('purgeCache', () => { expect(data).toBeNull() expect(error).toBeInstanceOf(StorageApiError) - expect(error?.message).toBe('Object not found') + expect(error?.message).toBe('Feature not enabled') }) it('forwards the AbortController signal to fetch', async () => { From 21a31f92df2a9c3bae201980dcedc8ba7fa40557 Mon Sep 17 00:00:00 2001 From: Lenny Date: Fri, 26 Jun 2026 15:05:43 -0400 Subject: [PATCH 5/5] feat(storage): expose transformations purge cache option --- packages/core/storage-js/src/lib/types.ts | 8 ++++ .../src/packages/StorageBucketApi.ts | 26 ++++++++++- .../storage-js/src/packages/StorageFileApi.ts | 20 ++++++++- .../storage-js/test/storageBucketApi.test.ts | 44 ++++++++++++++++++- .../storage-js/test/storageFileApi.test.ts | 44 ++++++++++++++++++- 5 files changed, 137 insertions(+), 5 deletions(-) diff --git a/packages/core/storage-js/src/lib/types.ts b/packages/core/storage-js/src/lib/types.ts index bb654bcb42..e2b2fd9f2b 100644 --- a/packages/core/storage-js/src/lib/types.ts +++ b/packages/core/storage-js/src/lib/types.ts @@ -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. diff --git a/packages/core/storage-js/src/packages/StorageBucketApi.ts b/packages/core/storage-js/src/packages/StorageBucketApi.ts index 153400de96..11cb90eb5d 100644 --- a/packages/core/storage-js/src/packages/StorageBucketApi.ts +++ b/packages/core/storage-js/src/packages/StorageBucketApi.ts @@ -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, FetchParameters, ListBucketOptions } from '../lib/types' +import { + Bucket, + BucketType, + FetchParameters, + ListBucketOptions, + PurgeCacheOptions, +} from '../lib/types' import { StorageClientOptions } from '../StorageClient' export default class StorageBucketApi extends BaseApiClient { @@ -407,6 +413,8 @@ export default class StorageBucketApi extends BaseApiClient { * @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. * @@ -416,9 +424,17 @@ export default class StorageBucketApi extends BaseApiClient { * .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< | { @@ -431,9 +447,15 @@ export default class StorageBucketApi extends BaseApiClient { } > { 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}`, + `${this.url}/cdn/${id}${queryString ? `?${queryString}` : ''}`, {}, { headers: this.headers }, parameters diff --git a/packages/core/storage-js/src/packages/StorageFileApi.ts b/packages/core/storage-js/src/packages/StorageFileApi.ts index 6e8cb96b2d..ca0d6ee439 100644 --- a/packages/core/storage-js/src/packages/StorageFileApi.ts +++ b/packages/core/storage-js/src/packages/StorageFileApi.ts @@ -19,6 +19,7 @@ import { Camelize, SearchV2Options, SearchV2Result, + PurgeCacheOptions, } from '../lib/types' import BlobDownloadBuilder from './BlobDownloadBuilder' @@ -1180,6 +1181,8 @@ export default class StorageFileApi extends BaseApiClient { * @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. * @@ -1190,9 +1193,18 @@ export default class StorageFileApi extends BaseApiClient { * .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< | { @@ -1206,9 +1218,15 @@ export default class StorageFileApi extends BaseApiClient { > { 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}`, + `${this.url}/cdn/${_path}${queryString ? `?${queryString}` : ''}`, {}, { headers: this.headers }, parameters diff --git a/packages/core/storage-js/test/storageBucketApi.test.ts b/packages/core/storage-js/test/storageBucketApi.test.ts index 8ef1e18e64..d3df74a016 100644 --- a/packages/core/storage-js/test/storageBucketApi.test.ts +++ b/packages/core/storage-js/test/storageBucketApi.test.ts @@ -589,6 +589,46 @@ describe('Bucket API Error Handling', () => { 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' }), { @@ -600,7 +640,9 @@ describe('Bucket API Error Handling', () => { const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' }) const controller = new AbortController() - const { error } = await client.purgeBucketCache(BUCKET, { signal: controller.signal }) + const { error } = await client.purgeBucketCache(BUCKET, undefined, { + signal: controller.signal, + }) expect(error).toBeNull() expect(fetchMock).toHaveBeenCalledWith( diff --git a/packages/core/storage-js/test/storageFileApi.test.ts b/packages/core/storage-js/test/storageFileApi.test.ts index 24d31191b9..1eb46a491c 100644 --- a/packages/core/storage-js/test/storageFileApi.test.ts +++ b/packages/core/storage-js/test/storageFileApi.test.ts @@ -1072,6 +1072,46 @@ describe('purgeCache', () => { 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.from(BUCKET).purgeCache(PATH, { transformations: true }) + + expect(error).toBeNull() + expect(data?.message).toBe('success') + expect(fetchMock).toHaveBeenCalledWith( + `${PURGE_URL}/cdn/${BUCKET}/${PATH}?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.from(BUCKET).purgeCache(PATH) + + expect(error).toBeNull() + expect(data?.message).toBe('success') + expect(fetchMock).toHaveBeenCalledWith( + `${PURGE_URL}/cdn/${BUCKET}/${PATH}`, + expect.objectContaining({ method: 'DELETE' }) + ) + }) + it('forwards the AbortController signal to fetch', async () => { const fetchMock = jest.fn().mockResolvedValue( new Response(JSON.stringify({ message: 'success' }), { @@ -1083,7 +1123,9 @@ describe('purgeCache', () => { const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' }) const controller = new AbortController() - const { error } = await client.from(BUCKET).purgeCache(PATH, { signal: controller.signal }) + const { error } = await client + .from(BUCKET) + .purgeCache(PATH, undefined, { signal: controller.signal }) expect(error).toBeNull() expect(fetchMock).toHaveBeenCalledWith(