diff --git a/apps/studio/components/interfaces/Storage/BucketVersioningFields.lifecycle.test.ts b/apps/studio/components/interfaces/Storage/BucketVersioningFields.lifecycle.test.ts new file mode 100644 index 00000000000..f928da3ad0e --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketVersioningFields.lifecycle.test.ts @@ -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) + }) +}) diff --git a/apps/studio/components/interfaces/Storage/BucketVersioningFields.lifecycle.ts b/apps/studio/components/interfaces/Storage/BucketVersioningFields.lifecycle.ts new file mode 100644 index 00000000000..fb8beb83499 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketVersioningFields.lifecycle.ts @@ -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', + } +} diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx index 14c1efab83f..ea01c0c6611 100644 --- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx @@ -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({ 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 - diff --git a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx index b3db24636be..cf583ccf045 100644 --- a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx @@ -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(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" > - + )} diff --git a/apps/studio/components/interfaces/Storage/EditBucketModal.utils.test.ts b/apps/studio/components/interfaces/Storage/EditBucketModal.utils.test.ts index 3bd484676eb..79543d9f62d 100644 --- a/apps/studio/components/interfaces/Storage/EditBucketModal.utils.test.ts +++ b/apps/studio/components/interfaces/Storage/EditBucketModal.utils.test.ts @@ -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() + }) +}) diff --git a/apps/studio/components/interfaces/Storage/EditBucketModal.utils.ts b/apps/studio/components/interfaces/Storage/EditBucketModal.utils.ts index f3a0f03321b..2ba62aa7d25 100644 --- a/apps/studio/components/interfaces/Storage/EditBucketModal.utils.ts +++ b/apps/studio/components/interfaces/Storage/EditBucketModal.utils.ts @@ -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 diff --git a/apps/studio/data/storage/bucket-create-mutation.ts b/apps/studio/data/storage/bucket-create-mutation.ts index 9628a6af997..35bf1670dbb 100644 --- a/apps/studio/data/storage/bucket-create-mutation.ts +++ b/apps/studio/data/storage/bucket-create-mutation.ts @@ -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', { diff --git a/apps/studio/data/storage/bucket-lifecycle-query.ts b/apps/studio/data/storage/bucket-lifecycle-query.ts new file mode 100644 index 00000000000..3bf19235ad6 --- /dev/null +++ b/apps/studio/data/storage/bucket-lifecycle-query.ts @@ -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 { + 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> + +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', + }) diff --git a/apps/studio/data/storage/bucket-lifecycle-update-mutation.ts b/apps/studio/data/storage/bucket-lifecycle-update-mutation.ts new file mode 100644 index 00000000000..d3ccf18f381 --- /dev/null +++ b/apps/studio/data/storage/bucket-lifecycle-update-mutation.ts @@ -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> + +export const useBucketLifecycleUpdateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions< + BucketLifecycleUpdateData, + ResponseError, + BucketLifecycleUpdateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + 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, + }) +} diff --git a/apps/studio/data/storage/bucket-update-mutation.ts b/apps/studio/data/storage/bucket-update-mutation.ts index 057a6659797..7f2f47c86df 100644 --- a/apps/studio/data/storage/bucket-update-mutation.ts +++ b/apps/studio/data/storage/bucket-update-mutation.ts @@ -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 { 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 = { 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 } }, diff --git a/apps/studio/data/storage/keys.ts b/apps/studio/data/storage/keys.ts index f6614f28357..4da7604208c 100644 --- a/apps/studio/data/storage/keys.ts +++ b/apps/studio/data/storage/keys.ts @@ -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: ({