mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 20:35:07 +03:00
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:
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 } },
|
||||
|
||||
@@ -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: ({
|
||||
|
||||
Reference in new issue
Block a user