feat(storage): save bucket versioning and its retention policy

The bucket endpoints now accept `versioning_status`, and the lifecycle
configuration has its own PUT/GET/DELETE beside the bucket, so the
versioning section stops being form state only.

- Create sends `DISABLED`/`ENABLED`, then writes the retention policy as a
  second call, since Storage stores it separately from the bucket
- Edit derives `ENABLED`/`SUSPENDED` through `toVersioningStatusUpdate`;
  `DISABLED` is not an accepted update, so a bucket that was never
  versioned sends no versioning field at all
- Edit reads the stored policy back to prefill, rather than assuming none

`toLifecycleRules`/`fromLifecycleRules` map the form onto the API's rules.
"Both conditions" is one rule; "either condition" needs two, because S3
only honors a version cap alongside an age. The policy is only written
when one of the three fields the form owns actually changed — a blind PUT
replaces the whole configuration and would drop rules the dashboard
cannot model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
This commit is contained in:
Claude authored and GitHub committed 2026-10-05 15:04:12 +02:00
1 parent 497869c909
commit 56e5e40884
11 files changed
+577 -18

No files matched your search

@@ -0,0 +1,197 @@
import { describe, expect, it } from 'vitest'
import {
AGE_RULE_ID,
COUNT_RULE_ID,
fromLifecycleRules,
hasLifecyclePolicyChanged,
toLifecycleRules,
} from './BucketVersioningFields.lifecycle'
const form = {
enable_versioning: true as const,
version_expiry_days: 30 as '' | number,
max_noncurrent_versions: 10 as '' | number,
expiration_mode: 'and' as const,
}
describe('toLifecycleRules', () => {
it('sends no rules when neither condition is set', () => {
expect(
toLifecycleRules({ ...form, version_expiry_days: '', max_noncurrent_versions: '' })
).toEqual([])
})
it('sends an age-only rule when no cap is set', () => {
expect(toLifecycleRules({ ...form, max_noncurrent_versions: '' })).toEqual([
{
id: AGE_RULE_ID,
status: 'Enabled',
filter: {},
noncurrent_version_expiration: { noncurrent_days: 30 },
},
])
})
it('combines both conditions into one rule for "and"', () => {
expect(toLifecycleRules(form)).toEqual([
{
id: AGE_RULE_ID,
status: 'Enabled',
filter: {},
noncurrent_version_expiration: { noncurrent_days: 30, newer_noncurrent_versions: 10 },
},
])
})
it('splits "or" into an age rule and a capped rule at the minimum age', () => {
const rules = toLifecycleRules({ ...form, expiration_mode: 'or' })
expect(rules).toHaveLength(2)
expect(rules[0]).toEqual({
id: AGE_RULE_ID,
status: 'Enabled',
filter: {},
noncurrent_version_expiration: { noncurrent_days: 30 },
})
expect(rules[1]).toEqual({
id: COUNT_RULE_ID,
status: 'Enabled',
filter: {},
noncurrent_version_expiration: { noncurrent_days: 1, newer_noncurrent_versions: 10 },
})
})
it('drops a cap with no age, which the API cannot express', () => {
expect(toLifecycleRules({ ...form, version_expiry_days: '' })).toEqual([])
})
})
describe('fromLifecycleRules', () => {
it('reports no policy when nothing is stored', () => {
expect(fromLifecycleRules(null)).toEqual({
versionExpiryDays: null,
maxNoncurrentVersions: null,
expirationMode: 'and',
})
})
it('reads a combined rule back as "and"', () => {
expect(
fromLifecycleRules({
rules: [
{
id: AGE_RULE_ID,
status: 'Enabled',
filter: {},
noncurrent_version_expiration: { noncurrent_days: 30, newer_noncurrent_versions: 10 },
},
],
})
).toEqual({ versionExpiryDays: 30, maxNoncurrentVersions: 10, expirationMode: 'and' })
})
it('reads the two-rule form back as "or"', () => {
expect(
fromLifecycleRules({
rules: [
{
id: AGE_RULE_ID,
status: 'Enabled',
filter: {},
noncurrent_version_expiration: { noncurrent_days: 30 },
},
{
id: COUNT_RULE_ID,
status: 'Enabled',
filter: {},
noncurrent_version_expiration: { noncurrent_days: 1, newer_noncurrent_versions: 10 },
},
],
})
).toEqual({ versionExpiryDays: 30, maxNoncurrentVersions: 10, expirationMode: 'or' })
})
it('ignores disabled rules', () => {
expect(
fromLifecycleRules({
rules: [
{
id: AGE_RULE_ID,
status: 'Disabled',
filter: {},
noncurrent_version_expiration: { noncurrent_days: 30 },
},
],
})
).toEqual({ versionExpiryDays: null, maxNoncurrentVersions: null, expirationMode: 'and' })
})
it('ignores rules with an action it cannot model', () => {
expect(fromLifecycleRules({ rules: [{ id: 'other', status: 'Enabled', filter: {} }] })).toEqual(
{
versionExpiryDays: null,
maxNoncurrentVersions: null,
expirationMode: 'and',
}
)
})
it('round-trips the "and" form', () => {
expect(fromLifecycleRules({ rules: toLifecycleRules(form) })).toEqual({
versionExpiryDays: 30,
maxNoncurrentVersions: 10,
expirationMode: 'and',
})
})
it('round-trips the "or" form', () => {
const rules = toLifecycleRules({ ...form, expiration_mode: 'or' })
expect(fromLifecycleRules({ rules })).toEqual({
versionExpiryDays: 30,
maxNoncurrentVersions: 10,
expirationMode: 'or',
})
})
})
describe('hasLifecyclePolicyChanged', () => {
const stored = {
versionExpiryDays: 30,
maxNoncurrentVersions: 10,
expirationMode: 'and' as const,
}
it('is false when nothing moved', () => {
expect(hasLifecyclePolicyChanged(stored, form)).toBe(false)
})
it('is true when the age changed', () => {
expect(hasLifecyclePolicyChanged(stored, { ...form, version_expiry_days: 60 })).toBe(true)
})
it('is true when the cap was cleared', () => {
expect(hasLifecyclePolicyChanged(stored, { ...form, max_noncurrent_versions: '' })).toBe(true)
})
it('is true when the mode changed and both conditions are set', () => {
expect(hasLifecyclePolicyChanged(stored, { ...form, expiration_mode: 'or' })).toBe(true)
})
it('ignores the mode when only one condition is set', () => {
const ageOnly = {
versionExpiryDays: 30,
maxNoncurrentVersions: null,
expirationMode: 'and' as const,
}
expect(
hasLifecyclePolicyChanged(ageOnly, {
...form,
max_noncurrent_versions: '',
expiration_mode: 'or',
})
).toBe(false)
})
})
@@ -0,0 +1,133 @@
import type { BucketVersioningFormValues } from './BucketVersioningFields.schema'
import type { ExpirationMode } from './StorageVersioning.constants'
import type { BucketLifecycle } from '@/data/storage/bucket-lifecycle-query'
import type { BucketLifecycleRuleInput } from '@/data/storage/bucket-lifecycle-update-mutation'
/**
* Storage keys its rules by id, and replacing the configuration means sending
* every rule we want to keep. Naming ours lets a round trip recognize what the
* dashboard wrote, rather than guessing from the rule's shape.
*/
export const AGE_RULE_ID = 'supabase-noncurrent-age'
export const COUNT_RULE_ID = 'supabase-noncurrent-count'
/**
* S3 has no count-only condition: `newer_noncurrent_versions` is only honored
* alongside `noncurrent_days`. "Either condition" is therefore two rules — one
* expiring purely by age, one expiring beyond the cap at the shortest age the
* API accepts. That floor is why the two modes aren't symmetrical.
*/
const MIN_NONCURRENT_DAYS = 1
export interface LifecycleFormPolicy {
versionExpiryDays: number | null
maxNoncurrentVersions: number | null
expirationMode: ExpirationMode
}
export const toLifecycleRules = (
values: Pick<
BucketVersioningFormValues,
'enable_versioning' | 'version_expiry_days' | 'max_noncurrent_versions' | 'expiration_mode'
>
): BucketLifecycleRuleInput[] => {
const days = values.version_expiry_days === '' ? null : values.version_expiry_days
const versions = values.max_noncurrent_versions === '' ? null : values.max_noncurrent_versions
if (days === null && versions === null) return []
if (days !== null && versions !== null && values.expiration_mode === 'or') {
return [
{
id: AGE_RULE_ID,
status: 'Enabled',
filter: {},
noncurrent_version_expiration: { noncurrent_days: days },
},
{
id: COUNT_RULE_ID,
status: 'Enabled',
filter: {},
noncurrent_version_expiration: {
noncurrent_days: MIN_NONCURRENT_DAYS,
newer_noncurrent_versions: versions,
},
},
]
}
// The count condition is meaningless without an age, and the form's own
// validation already rejects that combination.
if (days === null) return []
return [
{
id: AGE_RULE_ID,
status: 'Enabled',
filter: {},
noncurrent_version_expiration: {
noncurrent_days: days,
...(versions === null ? {} : { newer_noncurrent_versions: versions }),
},
},
]
}
/**
* Replacing the configuration sends every rule we want to keep, so a blind write
* would drop anything `fromLifecycleRules` could not model. Only write when the
* user actually moved one of the three fields the form owns.
*/
export const hasLifecyclePolicyChanged = (
stored: LifecycleFormPolicy,
values: Pick<
BucketVersioningFormValues,
'version_expiry_days' | 'max_noncurrent_versions' | 'expiration_mode'
>
) => {
const days = values.version_expiry_days === '' ? null : values.version_expiry_days
const versions = values.max_noncurrent_versions === '' ? null : values.max_noncurrent_versions
if (stored.versionExpiryDays !== days) return true
if (stored.maxNoncurrentVersions !== versions) return true
// The mode only means anything when both conditions are set.
return days !== null && versions !== null && stored.expirationMode !== values.expiration_mode
}
/**
* Reads back whatever Storage has stored, including configurations the dashboard
* did not write. Anything it can't model — extra rules, disabled rules, actions
* other than noncurrent expiration — is ignored rather than guessed at.
*/
export const fromLifecycleRules = (lifecycle?: BucketLifecycle | null): LifecycleFormPolicy => {
const enabledRules = (lifecycle?.rules ?? []).filter(
(rule) => rule.status === 'Enabled' && rule.noncurrent_version_expiration !== undefined
)
if (enabledRules.length === 0) {
return { versionExpiryDays: null, maxNoncurrentVersions: null, expirationMode: 'and' }
}
const ageOnlyRule = enabledRules.find(
(rule) => rule.noncurrent_version_expiration?.newer_noncurrent_versions === undefined
)
const cappedRule = enabledRules.find(
(rule) => rule.noncurrent_version_expiration?.newer_noncurrent_versions !== undefined
)
if (ageOnlyRule !== undefined && cappedRule !== undefined) {
return {
versionExpiryDays: ageOnlyRule.noncurrent_version_expiration?.noncurrent_days ?? null,
maxNoncurrentVersions:
cappedRule.noncurrent_version_expiration?.newer_noncurrent_versions ?? null,
expirationMode: 'or',
}
}
const rule = cappedRule ?? ageOnlyRule
return {
versionExpiryDays: rule?.noncurrent_version_expiration?.noncurrent_days ?? null,
maxNoncurrentVersions: rule?.noncurrent_version_expiration?.newer_noncurrent_versions ?? null,
expirationMode: 'and',
}
}
@@ -29,6 +29,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import z from 'zod'
import { BucketVersioningFields } from './BucketVersioningFields'
import { toLifecycleRules } from './BucketVersioningFields.lifecycle'
import {
bucketVersioningFormFields,
superRefineBucketVersioning,
@@ -41,6 +42,7 @@ import { StorageSizeUnits } from '@/components/interfaces/Storage/StorageSetting
import { InlineLink } from '@/components/ui/InlineLink'
import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query'
import { useBucketCreateMutation } from '@/data/storage/bucket-create-mutation'
import { useBucketLifecycleUpdateMutation } from '@/data/storage/bucket-lifecycle-update-mutation'
import { IS_PLATFORM } from '@/lib/constants'
import { useTrack } from '@/lib/telemetry/track'
@@ -120,6 +122,9 @@ export const CreateBucketModal = ({ open, onOpenChange }: CreateBucketModalProps
// [Joshen] Silencing the error here as it's being handled in onSubmit
onError: () => {},
})
const { mutateAsync: updateLifecycle, isPending: isUpdatingLifecycle } =
useBucketLifecycleUpdateMutation({ onError: () => {} })
const isSaving = isCreatingBucket || isUpdatingLifecycle
const form = useForm<CreateBucketForm>({
resolver: zodResolver(FormSchema),
@@ -153,9 +158,8 @@ export const CreateBucketModal = ({ open, onOpenChange }: CreateBucketModalProps
})
}
// TODO(storage-versioning): pass `enable_versioning`, `version_expiry_days`,
// `max_noncurrent_versions` and `expiration_mode` through once the API accepts them.
// Until then the versioning section is form state only.
const isVersioningEnabled = isStorageVersioningEnabled && values.enable_versioning
await createBucket({
projectRef: ref,
id: values.name,
@@ -163,10 +167,21 @@ export const CreateBucketModal = ({ open, onOpenChange }: CreateBucketModalProps
isPublic: values.public,
file_size_limit: fileSizeLimit,
allowed_mime_types: allowedMimeTypes,
// A bucket is only ever created DISABLED or ENABLED; suspending comes later.
versioning_status: isVersioningEnabled ? 'ENABLED' : 'DISABLED',
})
// Storage keeps the lifecycle policy separately from the bucket, so it is a
// second call. The bucket exists either way; a failure here leaves it
// versioned with no retention policy rather than rolling the creation back.
const lifecycleRules = isVersioningEnabled ? toLifecycleRules(values) : []
if (lifecycleRules.length > 0) {
await updateLifecycle({ projectRef: ref, bucketId: values.name, rules: lifecycleRules })
}
track('storage_bucket_created', {
bucketType: 'STANDARD',
hasVersioningEnabled: values.enable_versioning,
hasVersioningEnabled: isVersioningEnabled,
})
toast.success(`Successfully created bucket ${values.name}`)
@@ -402,15 +417,15 @@ export const CreateBucketModal = ({ open, onOpenChange }: CreateBucketModalProps
</Form>
<DialogFooter>
<Button disabled={isCreatingBucket} onClick={() => onOpenChange(false)}>
<Button disabled={isSaving} onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="primary"
form={formId}
type="submit"
loading={isCreatingBucket}
disabled={isCreatingBucket}
loading={isSaving}
disabled={isSaving}
>
Create
</Button>
@@ -1,4 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'common'
import { useEffect, useRef, useState } from 'react'
import { useForm, useWatch, type SubmitHandler } from 'react-hook-form'
@@ -30,14 +31,21 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { z } from 'zod'
import { BucketVersioningFields } from './BucketVersioningFields'
import {
fromLifecycleRules,
hasLifecyclePolicyChanged,
toLifecycleRules,
} from './BucketVersioningFields.lifecycle'
import {
bucketVersioningFormFields,
superRefineBucketVersioning,
} from './BucketVersioningFields.schema'
import {
getNextVersioningState,
getVersioningFormDefaults,
isEnablingVersioning,
isSuspendingVersioning,
toVersioningStatusUpdate,
type BucketVersioningSettings,
} from './EditBucketModal.utils'
import { getBucketVersioningState } from './StorageVersioning.constants'
@@ -49,6 +57,8 @@ import {
} from '@/components/interfaces/Storage/StorageSettings/StorageSettings.utils'
import { InlineLink } from '@/components/ui/InlineLink'
import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query'
import { bucketLifecycleQueryOptions } from '@/data/storage/bucket-lifecycle-query'
import { useBucketLifecycleUpdateMutation } from '@/data/storage/bucket-lifecycle-update-mutation'
import { useBucketUpdateMutation } from '@/data/storage/bucket-update-mutation'
import { Bucket } from '@/data/storage/buckets-query'
import { DOCS_URL, IS_PLATFORM } from '@/lib/constants'
@@ -91,22 +101,30 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro
const isStorageVersioningEnabled = useIsStorageVersioningEnabled()
const { data: lifecycle } = useQuery({
...bucketLifecycleQueryOptions({ projectRef: ref, bucketId: bucket?.id }),
enabled: isStorageVersioningEnabled && visible && !!ref && !!bucket?.id,
})
const storedPolicy = fromLifecycleRules(lifecycle)
const versioningSettings: BucketVersioningSettings = {
versioning: getBucketVersioningState(bucket),
versionExpiryDays: null,
maxNoncurrentVersions: null,
expirationMode: 'and',
versionExpiryDays: storedPolicy.versionExpiryDays,
maxNoncurrentVersions: storedPolicy.maxNoncurrentVersions,
expirationMode: storedPolicy.expirationMode,
}
// Held while the suspend confirmation is open, so confirming completes the save.
const [pendingSuspendValues, setPendingSuspendValues] = useState<BucketFormValues | null>(null)
const track = useTrack()
// The versioning fields aren't part of the update payload yet, so the intent is
// stashed here and only reported once the save actually succeeds.
// Stashed at submit time and only reported once the save actually succeeds.
const enabledVersioningRef = useRef<{ hasLifecyclePolicy: boolean } | null>(null)
const { mutate: updateBucket, isPending: isUpdating } = useBucketUpdateMutation({
const { mutateAsync: updateLifecycle, isPending: isUpdatingLifecycle } =
useBucketLifecycleUpdateMutation()
const { mutate: updateBucket, isPending: isUpdatingBucket } = useBucketUpdateMutation({
onSuccess: () => {
setPendingSuspendValues(null)
if (enabledVersioningRef.current !== null) {
@@ -150,6 +168,8 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro
},
})
const isUpdating = isUpdatingBucket || isUpdatingLifecycle
const defaultValues = {
name: bucket?.name ?? '',
public: bucket?.public,
@@ -183,7 +203,7 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro
onClose()
}
const persistChanges = (values: BucketFormValues) => {
const persistChanges = async (values: BucketFormValues) => {
if (bucket === undefined) return console.error('Bucket is required')
if (ref === undefined) return console.error('Project ref is required')
@@ -196,12 +216,39 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro
}
: null
// TODO(storage-versioning): pass the versioning fields through once the
// API accepts them. Until then the section is form state only.
const nextVersioningState = getNextVersioningState(
versioningSettings.versioning,
values.enable_versioning
)
const versioningStatus = isStorageVersioningEnabled
? toVersioningStatusUpdate(nextVersioningState)
: undefined
// The retention policy is stored beside the bucket, so it saves separately.
// Writing it first means a rejected policy leaves the bucket untouched.
// Suspending keeps the policy: retained versions still expire under it.
if (
isStorageVersioningEnabled &&
nextVersioningState !== 'disabled' &&
hasLifecyclePolicyChanged(storedPolicy, values)
) {
try {
await updateLifecycle({
projectRef: ref,
bucketId: bucket.id,
rules: toLifecycleRules(values),
})
} catch {
// The mutation raises its own toast; leave the modal open to retry.
return
}
}
updateBucket({
projectRef: ref,
id: bucket.id,
isPublic: values.public,
versioning_status: versioningStatus,
file_size_limit:
values.has_file_size_limit && values.formatted_size_limit
? convertToBytes(values.formatted_size_limit, selectedUnit as StorageSizeUnits)
@@ -246,7 +293,7 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro
return setPendingSuspendValues(values)
}
persistChanges(values)
await persistChanges(values)
}
useEffect(() => {
@@ -368,7 +415,11 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro
layout="flex"
>
<FormControl>
<Switch size="large" checked={field.value} onCheckedChange={field.onChange} />
<Switch
size="large"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItemLayout>
)}
@@ -5,6 +5,7 @@ import {
getVersioningFormDefaults,
isEnablingVersioning,
isSuspendingVersioning,
toVersioningStatusUpdate,
type BucketVersioningSettings,
} from './EditBucketModal.utils'
import { PROJECT_VERSIONING_DEFAULTS } from './StorageVersioning.constants'
@@ -133,3 +134,17 @@ describe('isEnablingVersioning', () => {
}
})
})
describe('toVersioningStatusUpdate', () => {
it('sends ENABLED when versioning is on', () => {
expect(toVersioningStatusUpdate('enabled')).toBe('ENABLED')
})
it('sends SUSPENDED when versioning is being turned off', () => {
expect(toVersioningStatusUpdate('suspended')).toBe('SUSPENDED')
})
it('sends nothing for a bucket that was never versioned', () => {
expect(toVersioningStatusUpdate('disabled')).toBeUndefined()
})
})
@@ -51,6 +51,19 @@ export const getNextVersioningState = (
return currentState === 'disabled' ? 'disabled' : 'suspended'
}
/**
* `DISABLED` is not an accepted update — the API has no way back once a bucket has
* been versioned, and a bucket that never was simply stays that way. Both cases
* mean sending no versioning field at all.
*/
export const toVersioningStatusUpdate = (
nextState: BucketVersioningState
): 'ENABLED' | 'SUSPENDED' | undefined => {
if (nextState === 'enabled') return 'ENABLED'
if (nextState === 'suspended') return 'SUSPENDED'
return undefined
}
export const isSuspendingVersioning = (
currentState: BucketVersioningState,
isVersioningEnabled: boolean
@@ -20,6 +20,7 @@ async function createBucket({
isPublic,
file_size_limit,
allowed_mime_types,
versioning_status,
}: BucketCreateVariables) {
if (!projectRef) throw new Error('projectRef is required')
if (!id) throw new Error('Bucket name is required')
@@ -28,6 +29,7 @@ async function createBucket({
if (type === 'STANDARD') {
if (file_size_limit) payload.file_size_limit = file_size_limit
if (allowed_mime_types) payload.allowed_mime_types = allowed_mime_types
if (versioning_status) payload.versioning_status = versioning_status
}
const { data, error } = await post('/platform/storage/{ref}/buckets', {
@@ -0,0 +1,50 @@
import { queryOptions } from '@tanstack/react-query'
import { components } from 'api-types'
import { storageKeys } from './keys'
import { get, handleError } from '@/data/fetchers'
import { IS_PLATFORM } from '@/lib/constants'
import type { ResponseError } from '@/types'
export type BucketLifecycle = components['schemas']['BucketLifecycleResponse_Output']
export type BucketLifecycleRule = BucketLifecycle['rules'][number]
export type BucketLifecycleVariables = {
projectRef?: string
bucketId?: string
}
export type BucketLifecycleError = ResponseError
/**
* A bucket with no stored configuration answers 404 rather than an empty list, so
* that is reported as "no policy" instead of surfacing as a query error.
*/
async function getBucketLifecycle(
{ projectRef, bucketId }: BucketLifecycleVariables,
signal?: AbortSignal
): Promise<BucketLifecycle | null> {
if (!projectRef) throw new Error('projectRef is required')
if (!bucketId) throw new Error('bucketId is required')
const { data, error } = await get('/platform/storage/{ref}/buckets/{id}/lifecycle', {
params: { path: { ref: projectRef, id: bucketId } },
signal,
})
if (error) {
if ((error as { code?: number }).code === 404) return null
handleError(error)
}
return data ?? null
}
export type BucketLifecycleData = Awaited<ReturnType<typeof getBucketLifecycle>>
export const bucketLifecycleQueryOptions = ({ projectRef, bucketId }: BucketLifecycleVariables) =>
queryOptions({
queryKey: storageKeys.bucketLifecycle(projectRef, bucketId),
queryFn: ({ signal }) => getBucketLifecycle({ projectRef, bucketId }, signal),
enabled: IS_PLATFORM && typeof projectRef !== 'undefined' && typeof bucketId !== 'undefined',
})
@@ -0,0 +1,77 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { components } from 'api-types'
import { toast } from 'sonner'
import { storageKeys } from './keys'
import { del, handleError, put } from '@/data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
export type UpdateBucketLifecycleBody = components['schemas']['UpdateBucketLifecycleBody']
export type BucketLifecycleRuleInput = UpdateBucketLifecycleBody['rules'][number]
export type BucketLifecycleUpdateVariables = {
projectRef: string
bucketId: string
/** An empty list removes the configuration — the API has no "zero rules" body. */
rules: BucketLifecycleRuleInput[]
}
async function updateBucketLifecycle({
projectRef,
bucketId,
rules,
}: BucketLifecycleUpdateVariables) {
if (!projectRef) throw new Error('projectRef is required')
if (!bucketId) throw new Error('bucketId is required')
if (rules.length === 0) {
const { error } = await del('/platform/storage/{ref}/buckets/{id}/lifecycle', {
params: { path: { ref: projectRef, id: bucketId } },
})
if (error) handleError(error)
return null
}
const { data, error } = await put('/platform/storage/{ref}/buckets/{id}/lifecycle', {
params: { path: { ref: projectRef, id: bucketId } },
body: { rules },
})
if (error) handleError(error)
return data
}
export type BucketLifecycleUpdateData = Awaited<ReturnType<typeof updateBucketLifecycle>>
export const useBucketLifecycleUpdateMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<
BucketLifecycleUpdateData,
ResponseError,
BucketLifecycleUpdateVariables
>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<BucketLifecycleUpdateData, ResponseError, BucketLifecycleUpdateVariables>({
mutationFn: updateBucketLifecycle,
async onSuccess(data, variables, context) {
await queryClient.invalidateQueries({
queryKey: storageKeys.bucketLifecycle(variables.projectRef, variables.bucketId),
})
await onSuccess?.(data, variables, context)
},
async onError(error, variables, context) {
if (onError === undefined) {
toast.error(`Failed to update the retention policy: ${error.message}`)
} else {
onError(error, variables, context)
}
},
...options,
})
}
@@ -12,6 +12,8 @@ type BucketUpdateVariables = {
isPublic: boolean
file_size_limit: number | null
allowed_mime_types: string[] | null
/** Omitted when versioning isn't changing. There is no going back to DISABLED. */
versioning_status?: components['schemas']['UpdateStorageBucketBody']['versioning_status']
}
// [Alaister]: API accept null values for allowed_mime_types and file_size_limit to reset
@@ -29,6 +31,7 @@ async function updateBucket({
isPublic,
file_size_limit,
allowed_mime_types,
versioning_status,
}: BucketUpdateVariables): Promise<BucketUpdateResult> {
if (!projectRef) throw new Error('projectRef is required')
if (!id) throw new Error('Bucket name is required')
@@ -36,6 +39,7 @@ async function updateBucket({
const payload: Partial<UpdateStorageBucketBody> = { public: isPublic }
if (file_size_limit !== undefined) payload.file_size_limit = file_size_limit
if (allowed_mime_types !== undefined) payload.allowed_mime_types = allowed_mime_types
if (versioning_status !== undefined) payload.versioning_status = versioning_status
const { data, error } = await patch('/platform/storage/{ref}/buckets/{id}', {
params: { path: { id, ref: projectRef } },
+2
View File
@@ -56,6 +56,8 @@ export const storageKeys = {
] as const,
folders: (projectRef: string | undefined, bucketId: string | undefined) =>
['projects', projectRef, 'buckets', bucketId, 'folders'] as const,
bucketLifecycle: (projectRef: string | undefined, bucketId: string | undefined) =>
['projects', projectRef, 'buckets', bucketId, 'lifecycle'] as const,
icebergNamespaces: ({ projectRef, warehouse }: { projectRef?: string; warehouse?: string }) =>
[projectRef, 'warehouse', warehouse, 'namespaces'] as const,
icebergNamespace: ({