Skip to content
Open
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: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions SDK-REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type {
CreateUserRequest,
CreateSessionRequest,
AuthErrorResponse,
DeleteObjectResult,
DeleteObjectsResponse,
} from '@insforge/shared-schemas';

// Re-export auth module for advanced usage
Expand Down
87 changes: 87 additions & 0 deletions src/modules/__tests__/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
});
});
35 changes: 28 additions & 7 deletions src/modules/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
data: T | null;
Expand Down Expand Up @@ -462,15 +466,32 @@ export class StorageBucket {
}
}

/** Delete a single file. */
async remove(path: string): Promise<StorageResponse<{ message: string }>>;

/** Delete multiple files in a single request. */
async remove(paths: string[]): Promise<StorageResponse<DeleteObjectsResponse>>;

/**
* 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<StorageResponse<{ message: string }>> {
async remove(
pathOrPaths: string | string[]
): Promise<StorageResponse<{ message: string } | DeleteObjectsResponse>> {
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<DeleteObjectsResponse>(
`/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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error is null even when every key failed. Every other method in this module sets error on failure, so a caller following the pattern this SDK trains (if (error) …) will read a fully-failed batch as success. The pass-through { results } shape (see the RemoveManyOutcome comment) sidesteps this since each entry's status is self-describing; if you keep the aggregated shape, this asymmetry is worth calling out even more prominently in the docs.

Expand Down