Skip to content

Commit 882c802

Browse files
committed
fix(tools): update supabase buckets atomically
1 parent ddcbb26 commit 882c802

2 files changed

Lines changed: 72 additions & 45 deletions

File tree

apps/sim/lib/internal/supabase/operations/storage-update-bucket.test.ts

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,36 +22,39 @@ describe('executeStorageUpdateBucketOperation', () => {
2222
vi.unstubAllGlobals()
2323
})
2424

25-
it('passes cancellation to both bucket requests', async () => {
25+
it('sends only explicitly changed fields in one non-redirecting update request', async () => {
2626
const controller = new AbortController()
27-
fetchMock
28-
.mockResolvedValueOnce(Response.json({ public: false, file_size_limit: 100 }))
29-
.mockResolvedValueOnce(Response.json({ id: 'documents' }))
27+
fetchMock.mockResolvedValueOnce(Response.json({ message: 'Successfully updated' }))
3028

3129
await executeStorageUpdateBucketOperation({ ...INPUT, isPublic: true }, controller.signal)
3230

33-
expect(fetchMock).toHaveBeenCalledTimes(2)
34-
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ signal: controller.signal })
35-
expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ signal: controller.signal })
31+
expect(fetchMock).toHaveBeenCalledTimes(1)
32+
expect(fetchMock).toHaveBeenCalledWith(
33+
'https://projectref.supabase.co/storage/v1/bucket/documents',
34+
expect.objectContaining({
35+
method: 'PUT',
36+
body: JSON.stringify({ public: true }),
37+
redirect: 'error',
38+
signal: controller.signal,
39+
})
40+
)
3641
})
3742

38-
it('treats whitespace-only file limits as omitted', async () => {
39-
fetchMock
40-
.mockResolvedValueOnce(Response.json({ public: false, file_size_limit: 4096 }))
41-
.mockResolvedValueOnce(Response.json({ id: 'documents' }))
43+
it('treats whitespace-only file limits as omitted without reading the bucket', async () => {
44+
fetchMock.mockResolvedValueOnce(Response.json({ message: 'Successfully updated' }))
4245

4346
await executeStorageUpdateBucketOperation({
4447
...INPUT,
48+
isPublic: false,
4549
fileSizeLimit: ' ' as never,
4650
})
4751

48-
const payload = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))
49-
expect(payload.file_size_limit).toBe(4096)
52+
expect(fetchMock).toHaveBeenCalledTimes(1)
53+
const payload = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))
54+
expect(payload).toEqual({ public: false })
5055
})
5156

5257
it('rejects a nonnumeric file limit before updating the bucket', async () => {
53-
fetchMock.mockResolvedValueOnce(Response.json({ public: false, file_size_limit: 4096 }))
54-
5558
const result = await executeStorageUpdateBucketOperation({
5659
...INPUT,
5760
fileSizeLimit: 'not-a-number' as never,
@@ -61,12 +64,10 @@ describe('executeStorageUpdateBucketOperation', () => {
6164
success: false,
6265
error: 'File size limit must be a finite number',
6366
})
64-
expect(fetchMock).toHaveBeenCalledTimes(1)
67+
expect(fetchMock).not.toHaveBeenCalled()
6568
})
6669

6770
it.each([true, [], {}, '0x100'])('rejects a non-decimal file limit %j', async (fileSizeLimit) => {
68-
fetchMock.mockResolvedValueOnce(Response.json({ public: false, file_size_limit: 4096 }))
69-
7071
const result = await executeStorageUpdateBucketOperation({
7172
...INPUT,
7273
fileSizeLimit: fileSizeLimit as never,
@@ -76,7 +77,29 @@ describe('executeStorageUpdateBucketOperation', () => {
7677
success: false,
7778
error: 'File size limit must be a finite number',
7879
})
79-
expect(fetchMock).toHaveBeenCalledTimes(1)
80+
expect(fetchMock).not.toHaveBeenCalled()
81+
})
82+
83+
it('preserves a no-op update while still verifying access to the bucket', async () => {
84+
fetchMock.mockResolvedValueOnce(Response.json({ id: 'documents' }))
85+
86+
const result = await executeStorageUpdateBucketOperation({
87+
...INPUT,
88+
fileSizeLimit: ' ' as never,
89+
})
90+
91+
expect(result).toEqual({
92+
success: true,
93+
output: {
94+
message: 'Successfully updated storage bucket',
95+
results: { message: 'Successfully updated' },
96+
},
97+
error: undefined,
98+
})
99+
expect(fetchMock).toHaveBeenCalledWith(
100+
'https://projectref.supabase.co/storage/v1/bucket/documents',
101+
expect.objectContaining({ method: 'GET', redirect: 'error' })
102+
)
80103
})
81104

82105
it('propagates cancellation instead of returning a failed tool envelope', async () => {
@@ -87,7 +110,7 @@ describe('executeStorageUpdateBucketOperation', () => {
87110
})
88111

89112
await expect(
90-
executeStorageUpdateBucketOperation(INPUT, controller.signal)
113+
executeStorageUpdateBucketOperation({ ...INPUT, isPublic: true }, controller.signal)
91114
).rejects.toMatchObject({ name: 'AbortError' })
92115
})
93116

apps/sim/lib/internal/supabase/operations/storage-update-bucket.ts

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getErrorMessage } from '@sim/utils/errors'
2+
import { filterUndefined } from '@sim/utils/object'
23
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
34
import type {
45
SupabaseStorageUpdateBucketParams,
@@ -20,23 +21,6 @@ export const executeStorageUpdateBucketOperation: InternalToolOperationImplement
2021
Authorization: `Bearer ${params.apiKey}`,
2122
'Content-Type': 'application/json',
2223
}
23-
const currentResponse = await fetch(`${baseUrl}/storage/v1/bucket/${bucket}`, {
24-
method: 'GET',
25-
headers,
26-
signal,
27-
})
28-
29-
if (!currentResponse.ok) {
30-
const errorText = await currentResponse.text()
31-
throw new Error(`Failed to read current bucket configuration: ${errorText}`)
32-
}
33-
34-
const current = await currentResponse.json()
35-
36-
// Block subBlocks for a shared field can forward an empty string
37-
// (e.g. an untouched short-input) rather than omitting the key
38-
// entirely — treat that the same as "not provided" so it falls
39-
// back to the bucket's current value instead of coercing to 0/false.
4024
const hasValue = (value: unknown): boolean =>
4125
value !== undefined && value !== null && (typeof value !== 'string' || value.trim() !== '')
4226
const rawFileSizeLimit: unknown = params.fileSizeLimit
@@ -52,20 +36,40 @@ export const executeStorageUpdateBucketOperation: InternalToolOperationImplement
5236
throw new Error('File size limit must be a finite number')
5337
}
5438

55-
const payload: Record<string, unknown> = {
56-
id: params.bucket,
57-
name: params.bucket,
58-
public: hasValue(params.isPublic) ? params.isPublic : Boolean(current.public),
59-
file_size_limit: fileSizeLimit ?? current.file_size_limit ?? null,
60-
allowed_mime_types: hasValue(params.allowedMimeTypes)
61-
? params.allowedMimeTypes
62-
: (current.allowed_mime_types ?? null),
39+
const payload = filterUndefined({
40+
public: hasValue(params.isPublic) ? params.isPublic : undefined,
41+
file_size_limit: fileSizeLimit,
42+
allowed_mime_types: hasValue(params.allowedMimeTypes) ? params.allowedMimeTypes : undefined,
43+
})
44+
45+
if (Object.keys(payload).length === 0) {
46+
const currentResponse = await fetch(`${baseUrl}/storage/v1/bucket/${bucket}`, {
47+
method: 'GET',
48+
headers,
49+
redirect: 'error',
50+
signal,
51+
})
52+
if (!currentResponse.ok) {
53+
const errorText = await currentResponse.text()
54+
throw new Error(`Failed to read current bucket configuration: ${errorText}`)
55+
}
56+
await currentResponse.body?.cancel()
57+
signal?.throwIfAborted()
58+
return {
59+
success: true,
60+
output: {
61+
message: 'Successfully updated storage bucket',
62+
results: { message: 'Successfully updated' },
63+
},
64+
error: undefined,
65+
}
6366
}
6467

6568
const updateResponse = await fetch(`${baseUrl}/storage/v1/bucket/${bucket}`, {
6669
method: 'PUT',
6770
headers,
6871
body: JSON.stringify(payload),
72+
redirect: 'error',
6973
signal,
7074
})
7175

0 commit comments

Comments
 (0)