diff --git a/README.md b/README.md index e156864..dd004d2 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,13 @@ const { data, error } = await insforge.storage.from('avatars').upload(file); const { data, error } = await insforge.storage.from('avatars').download('user-avatar.png'); // Delete a file -const { data, error } = await insforge.storage.from('avatars').remove(['user-avatar.png']); +const { data, error } = await insforge.storage.from('avatars').remove('user-avatar.png'); + +// Delete multiple files (maximum 1000 keys) +const { data, error } = await insforge.storage + .from('avatars') + .remove(['user-avatar.png', 'old-avatar.png']); +// data: { results: [{ key, status: 'deleted' | 'notFound' | 'failed', message? }] } // List files const { data, error } = await insforge.storage.from('avatars').list(); diff --git a/SDK-REFERENCE.md b/SDK-REFERENCE.md index e297b7c..5d28d20 100644 --- a/SDK-REFERENCE.md +++ b/SDK-REFERENCE.md @@ -742,8 +742,14 @@ await bucket.list({ prefix: 'users/', limit: 10 }); ### `bucket.remove()` ```javascript +// Delete one object await bucket.remove('path/file.jpg'); // Response: { data: { message }, error } + +// Delete multiple objects in one request (maximum 1000 keys) +await bucket.remove(['path/a.jpg', 'path/b.txt']); +// Response: { data: { results }, error } +// results: { key, status: 'deleted' | 'notFound' | 'failed', message? }[] ``` ### `bucket.getPublicUrl()` diff --git a/package-lock.json b/package-lock.json index 71c7e89..4c5d3fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.4.3", "license": "Apache-2.0", "dependencies": { - "@insforge/shared-schemas": "^1.1.58", + "@insforge/shared-schemas": "^1.2.0", "@supabase/postgrest-js": "^1.21.3", "socket.io-client": "^4.8.1" }, @@ -785,9 +785,9 @@ } }, "node_modules/@insforge/shared-schemas": { - "version": "1.1.58", - "resolved": "https://registry.npmjs.org/@insforge/shared-schemas/-/shared-schemas-1.1.58.tgz", - "integrity": "sha512-of494/PttH+nf5SBz63mAtgCBimDPIDF/tPEYb9zMNOySsGYNXYXWyqcyPuxnQcVuThlV5cdUwxW3cImvXyAvw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@insforge/shared-schemas/-/shared-schemas-1.2.0.tgz", + "integrity": "sha512-qUYNXkCKVPTKXC/jWtG+FEgH/NFY0d2EnEgjYj8vu+R6wezGpKXP8T0rSMtoavgXo1ZcOTCR8+pQJBWJSR9BvA==", "license": "Apache-2.0", "dependencies": { "zod": "^3.23.8" diff --git a/package.json b/package.json index 891aa8b..a722795 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "url": "https://github.com/InsForge/insforge-sdk-js/issues" }, "dependencies": { - "@insforge/shared-schemas": "^1.1.58", + "@insforge/shared-schemas": "^1.2.0", "@supabase/postgrest-js": "^1.21.3", "socket.io-client": "^4.8.1" }, diff --git a/src/index.ts b/src/index.ts index 0b56295..c539eef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,8 @@ export type { CreateUserRequest, CreateSessionRequest, AuthErrorResponse, + DeleteObjectResult, + DeleteObjectsResponse, } from '@insforge/shared-schemas'; // Re-export auth module for advanced usage diff --git a/src/modules/__tests__/storage.test.ts b/src/modules/__tests__/storage.test.ts index 3a17589..3db8753 100644 --- a/src/modules/__tests__/storage.test.ts +++ b/src/modules/__tests__/storage.test.ts @@ -3,6 +3,7 @@ import { StorageBucket } from '../storage'; import { HttpClient } from '../../lib/http-client'; import { InsForgeError } from '../../types'; import { TokenManager } from '../../lib/token-manager'; +import type { DeleteObjectsResponse } from '@insforge/shared-schemas'; function makeTokenManager(): TokenManager { return { @@ -174,3 +175,89 @@ describe('StorageBucket.createSignedUrls', () => { expect(new URL(String(fetchFn.mock.calls[0][0])).searchParams.get('expiresIn')).toBe('60'); }); }); + +describe('StorageBucket.remove', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('deletes a single path with the single-object endpoint', async () => { + const fetchFn = vi.fn().mockResolvedValue(jsonRes(200, { message: 'Object deleted' })); + const bucket = new StorageBucket('docs', makeHttp(fetchFn)); + + const result = await bucket.remove('files/old report.pdf'); + + expect(result).toEqual({ data: { message: 'Object deleted' }, error: null }); + expect(fetchFn).toHaveBeenCalledOnce(); + expect(new URL(String(fetchFn.mock.calls[0][0])).pathname).toBe( + '/api/storage/buckets/docs/objects/files%2Fold%20report.pdf' + ); + expect(fetchFn.mock.calls[0][1]?.method).toBe('DELETE'); + }); + + it('deletes multiple paths in one request and returns the server results unchanged', async () => { + const response = { + results: [ + { key: 'a.pdf', status: 'deleted' }, + { key: 'missing.pdf', status: 'notFound' }, + { key: 'locked.pdf', status: 'failed', message: 'Delete denied' }, + ], + } satisfies DeleteObjectsResponse; + const fetchFn = vi.fn().mockResolvedValue(jsonRes(200, response)); + const bucket = new StorageBucket('docs', makeHttp(fetchFn)); + + const result = await bucket.remove(['a.pdf', 'missing.pdf', 'locked.pdf']); + + expect(result).toEqual({ data: response, error: null }); + expect(fetchFn).toHaveBeenCalledOnce(); + expect(new URL(String(fetchFn.mock.calls[0][0])).pathname).toBe( + '/api/storage/buckets/docs/objects' + ); + expect(fetchFn.mock.calls[0][1]?.method).toBe('DELETE'); + expect(JSON.parse(String(fetchFn.mock.calls[0][1]?.body))).toEqual({ + keys: ['a.pdf', 'missing.pdf', 'locked.pdf'], + }); + }); + + it('surfaces a batch request error through the storage response envelope', async () => { + const fetchFn = vi + .fn() + .mockResolvedValue( + jsonRes( + 400, + { error: 'STORAGE_ERROR', message: 'At least one object key is required' }, + 'Bad Request' + ) + ); + const bucket = new StorageBucket('docs', makeHttp(fetchFn)); + + const result = await bucket.remove([]); + + expect(result.data).toBeNull(); + expect(result.error).toBeInstanceOf(InsForgeError); + expect(result.error?.statusCode).toBe(400); + expect(result.error?.message).toBe('At least one object key is required'); + expect(fetchFn).toHaveBeenCalledOnce(); + }); + + it('does not split arrays that exceed the server limit into multiple requests', async () => { + const paths = Array.from({ length: 1001 }, (_, index) => `file-${index}.txt`); + const fetchFn = vi + .fn() + .mockResolvedValue( + jsonRes( + 400, + { error: 'STORAGE_ERROR', message: 'Cannot delete more than 1000 objects at once' }, + 'Bad Request' + ) + ); + const bucket = new StorageBucket('docs', makeHttp(fetchFn)); + + const result = await bucket.remove(paths); + + expect(result.data).toBeNull(); + expect(result.error?.statusCode).toBe(400); + expect(fetchFn).toHaveBeenCalledOnce(); + expect(JSON.parse(String(fetchFn.mock.calls[0][1]?.body)).keys).toHaveLength(1001); + }); +}); diff --git a/src/modules/storage.ts b/src/modules/storage.ts index 2ffd5dd..e497def 100644 --- a/src/modules/storage.ts +++ b/src/modules/storage.ts @@ -5,7 +5,11 @@ import { HttpClient } from '../lib/http-client'; import { InsForgeError } from '../types'; -import type { StorageFileSchema, ListObjectsResponseSchema } from '@insforge/shared-schemas'; +import type { + StorageFileSchema, + ListObjectsResponseSchema, + DeleteObjectsResponse, +} from '@insforge/shared-schemas'; export interface StorageResponse { data: T | null; @@ -462,15 +466,32 @@ export class StorageBucket { } } + /** Delete a single file. */ + async remove(path: string): Promise>; + + /** Delete multiple files in a single request. */ + async remove(paths: string[]): Promise>; + /** - * Delete a file - * @param path - The object key/path + * Delete one or more files. + * + * A string uses the single-object endpoint. An array uses the batch endpoint, + * which accepts at most 1000 keys and returns one result per key. + * + * @param pathOrPaths - One object key/path or an array of object keys/paths */ - async remove(path: string): Promise> { + async remove( + pathOrPaths: string | string[] + ): Promise> { try { - const response = await this.http.delete<{ message: string }>( - `/api/storage/buckets/${this.bucketName}/objects/${encodeURIComponent(path)}` - ); + const response = Array.isArray(pathOrPaths) + ? await this.http.delete( + `/api/storage/buckets/${this.bucketName}/objects`, + { body: { keys: pathOrPaths } } + ) + : await this.http.delete<{ message: string }>( + `/api/storage/buckets/${this.bucketName}/objects/${encodeURIComponent(pathOrPaths)}` + ); return { data: response, error: null }; } catch (error) {