Splits Storage Size into current versus noncurrent objects, and attributes the
noncurrent portion to the buckets responsible, so the cost of object versioning
sits with the metric it inflates.
The split is flag-aware. With the preview off, Storage Size renders exactly the
single real series it does today; `USAGE_CATEGORIES` takes an options argument
and only emits the two stacked segments when the preview is on.
Noncurrent is deliberately not split further into noncurrent versions and
soft-deleted files: the platform can't reliably tell the two apart after the
fact, and the user's lever for shrinking either is the same lifecycle policy.
Using S3's own terms also reads better than Studio-only words like "live".
- `storage-retention-usage-query.ts` — org-scoped (the prototype keyed this
org-level data under `['projects', undefined, …]`), real `queryOptions` shape
with the endpoint stubbed
- `StorageRetention.constants.ts` — one definition of the two segments, used by
both the chart attributes and the breakdown table, so labels, colors and key
casing cannot drift
- `StorageRetention.utils.ts` + tests — maps retention days onto chart points
- `StorageRetentionBreakdown.tsx` — the segment table, retained-data warning,
and per-bucket list, rendered through the existing `additionalInfo` hook
- `Usage.colors.ts` — `COLOR_MAP` and `AttributeColor` moved out of
`Usage.constants` so the breakdown can read a color token without a cycle
back through the module that renders it
Known limitation: with the preview on, the stacked chart and the breakdown read
zeros until the retention endpoint exists. No mock data is shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Leads with what happened and that it is permanent, then bounds the wait.
The previous wording blamed a missing schedule; the real reason there is no
exact timestamp is that the cleanup spans several systems.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
"Expiring now" claimed a moment that does not exist: cleanup is a periodic
pass, so a version that has met the policy stays listed until it runs. The
tooltip says why, within a max width.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
Restoring a noncurrent version invalidated the version list but left the
explorer row showing the old size, type and modified date, since the live
listing is the explorer's own state rather than a React Query cache.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
The version history rendered a generic mime-type icon per row, and the
compare widget put that same icon on both sides — so every entry for a
file looked identical and the comparison showed nothing to compare.
The sign and public-url endpoints already accept `options.versionId`;
nothing asked for it. `useFetchFileUrlQuery` now takes a `versionId` and
keys on it, and the preview rendering moves out of `PreviewPane` into a
`FilePreview` component both the pane and the compare widget use, so a
version preview cannot silently fall back to the current bytes.
Image rows in the history list render their own thumbnail under 5MB;
larger files and other mime types keep the icon rather than pull a whole
object down for a 28px box.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
The file preview panel already splits the two on a versioned bucket, but
the explorer's own row menu still offered a single "Delete" — which
archives there, without saying so.
The row menu now reads "Archive" on a versioned bucket and gains a
"Delete permanently" entry beside it.
`ConfirmPurgeModal` is mounted once by the explorer and driven by
`itemToPurge` on the store, the same shape the row delete already uses.
The preview panel routes its own permanent delete through it too, so the
confirmation copy exists in one place rather than two.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
The preview panel now addresses versions by the object's full path, which
is what the list, move and delete endpoints take, rather than by the leaf
name the explorer renders. `VersionHistory` keeps `objectName` for copy
and takes `path` separately.
Expiry countdowns read the bucket's stored lifecycle policy instead of an
empty placeholder, so a row's fate reflects the policy that governs it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
The empty state read "Overwriting this file will retain a recoverable copy here"
regardless of whether the bucket was actually versioned. Since
`getBucketVersioningState` reports `disabled` for every bucket until the API
exposes the field, that promise was showing on every file in every bucket.
Splits it: `disabled` now says versioning is off and points at the bucket
settings; `enabled`/`suspended` keep the original copy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follows the same change in the version-fate helper: a version cap always arrives
alongside an expiration age, so the policy summary only has three shapes to
describe (age alone, or age plus cap under either operator). Removes the
cap-only sentence and the "no age limit" chip, and `daysRemaining` on
`expires-on-next-upload` no longer needs an undefined guard.
Also adds the explicit `tabIndex={0}` that `supabase/require-explicit-tabindex`
wants on the four raw buttons in this feature. These were lint *errors*, not
warnings, so the ratchet never surfaced them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Completes the rename in the version history rows, the lifecycle policy summary,
and the delete confirmation copy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rebuilds the file preview panel around object versioning: a collapsible Versions
section listing every version with its removal outlook, an inline
compare-and-restore widget, and delete actions that say what they actually do on
a versioned bucket.
- `VersionHistory` + `VersionHistoryPolicyRow` + `VersionThumbnail` — the version
list, the inline lifecycle policy summary, and the row glyph
- `VersionCompareWidget` — takes over the top of the panel when a noncurrent
version is selected, so restoring is a visible comparison, not a modal
- `PreviewSection` — the collapsible section wrapper
- `PreviewPane` — new panel chrome, viewport-clamped thumbnail, and an
Archive / Delete permanently split button on versioned buckets
- `ConfirmDeleteModal` — on a versioned bucket a delete is a soft delete, so the
copy no longer claims it cannot be undone
Delete markers are surfaced as their own row type. They are the empty
placeholders S3 writes on a soft delete, and they can outlive the delete
(delete → upload → delete → restore leaves one mid-history), so a live file's
history can contain them. There is nothing to preview or restore, so the row is
non-interactive, dimmed, labelled "Delete marker", and its only action is
removing the marker itself.
Version data comes from the stubbed query added in the previous PR, so the
Versions section renders its empty state until the Storage API lands.
Fixes carried over from the prototype rather than ported:
- Version rows were a clickable `<li>` whose `onKeyDown` passed a function
reference instead of calling it, so keyboard activation did nothing. They are
real `<button>`s now, which fixes activation and a11y together.
- The policy summary put a `<Button>` inside tooltip content, unreachable by
pointer or keyboard — now a `HoverCard`.
- Permanent delete fired a bare `toast.success` with no mutation behind it.
- Dropped the download and "Get version URL" menu items, which were toast stubs.
- Deduplicated a double `filter` over versions and memoized the fate map.
Also re-syncs `selectedBucket` in the explorer store when the bucket query
refetches. It was only ever seeded once, so editing a bucket left consumers
reading stale metadata — which is how Copy URL could sign a URL for the wrong
visibility after a public/private toggle.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
In "either condition" mode the cap is written as a separate rule pinned at
`noncurrent_days: 1`, because S3 honors a version cap only alongside an age.
A version beyond the cap was reported as expiring the moment it went
noncurrent, while nothing would touch it until it was a day old.
Also renames the fate to `expiry-due`, since removal happens on the next
cleanup pass rather than at the instant the policy is met.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
The object list endpoint now takes `noncurrentVersions`, `deleteMarkers`
and `exactMatch`, and returns `version`, `archived_at` and
`is_delete_marker` per row, so the version history has a real source.
- `objectVersionsQueryOptions` lists one object's history, with
`exactMatch` so the path isn't read as a folder prefix
- Restoring is a move onto the object's own path with `sourceVersionId`,
which consumes the source version rather than duplicating it
- Deleting one version addresses it as `{ path, versionId }`, which is a
hard delete even on a versioned bucket
- Purging reads the history first and deletes every version by id, since
a bare path would only hide whatever is current
Variables take the object's full bucket-relative `path` rather than its
leaf name, which is what these endpoints address.
`ObjectVersionAction` drops `restore`: the list endpoint has no
provenance field, so a restore is indistinguishable from an overwrite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
A version cap with no expiration age isn't expressible in S3 — `NoncurrentDays`
is required on any `NoncurrentVersionExpiration` rule — and the bucket form now
disables the cap until an age is set. So `computeVersionFate` no longer has a
cap-only path: no age means no policy.
That also makes `expires-on-next-upload` reachable only from the `or` branch,
where an age is always present, so `daysRemaining` is required rather than
optional.
The cap-only test block is replaced by one asserting a lone cap is ignored, so a
cap arriving without an age can never start labelling versions for removal.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Renames the `VersionFate` variant and its test wording to match the term used in
the form copy and the version history rows.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the query and mutation hooks the version history UI needs, written to the
studio `queryOptions` conventions with the endpoint calls stubbed. No mock
fixtures: `useObjectVersionsQuery` resolves to an empty list until the Storage
API exists, so the version history renders its empty state rather than
fabricated data, and swapping in the real endpoint is a one-function change per
file.
- `object-versions-query.ts` — the version list, plus the `ObjectVersion` and
`LifecyclePolicy` types
- `object-version-restore-mutation.ts` — promote a noncurrent version to current
- `object-version-delete-mutation.ts` — remove one specific version
- `object-purge-mutation.ts` — delete an object and all of its versions,
bypassing versioning
- `VersionHistory.utils.ts` — `computeVersionFate`, the pure rule deciding what
removal outlook each version row shows
- `BroomSparklesIcon.tsx` — inline SVG for a glyph absent from lucide-react 0.436
The bucket's lifecycle policy is part of the `objectVersions` query key. The
prototype read it inside `queryFn` while leaving it out of the key, which left
stale expiry labels behind after a policy edit.
`computeVersionFate` is carried over from the prototype with its 9 reference
cases intact, minus four non-null assertions — the bounds are now narrowed
through a nullable local instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A failed fetch left the form seeded with an empty policy and the save enabled,
so submitting replaced the bucket's real rules with it. Pass the query error
through to the section and keep Save disabled until the policy resolves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
The update endpoint replaces a bucket's whole lifecycle policy, and the form
models only a one- or two-rule noncurrent-expiration policy, so saving a
bucket configured through the S3 or Storage API dropped the rest of it. Detect
a policy outside that shape, show it as read-only, and skip the write.
A rejected retention policy also reported itself as a failed bucket creation,
leaving the user to retry a name that now exists. Report it against the policy
instead, and close the modal on the bucket that was in fact created.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
The edit modal seeded the form before the policy request returned, so an
enabled bucket showed the no-policy warning for a beat and then filled
itself in. The section now reports loading, and saving is held until the
policy lands: submitting during that window wrote the empty seed over a
real policy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
Two separate reasons the list kept serving a stale row.
`bucketsList` always writes all four params, so a filter built from
`bucketsList(ref)` carries `undefined` for each. React Query's partial
match compares the keys the filter supplies, and `undefined` never
matches the `sortColumn: 'name'` the list is actually registered under —
so the delete mutation's invalidation matched nothing at all, and a
deleted bucket stayed on screen. Both mutations now key on `buckets`, a
plain array prefix of every bucket key.
Invalidation also defaulted to refetching only active queries. The edit
modal and the delete modal both live on the bucket page, where the list
query is inactive, so it was left marked stale rather than refetched —
including the versioning badge that the bucket update had just changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
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
Three fixes to the suspend-versioning dialog:
- `variant="warning"`, matching the inline admonition for the same state. It was
rendering in the neutral default, softer than the notice that precedes it.
- The dialog no longer closes synchronously on confirm, so `loading` and the new
`confirmLabelLoading` are actually observable. Previously the user got no
feedback between confirming and the modal vanishing.
- The pending values are cleared from the mutation's `onSuccess`/`onError`
instead, so a failed save releases the dialog rather than trapping the user.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds `storage_bucket_versioning_enabled`, fired when a bucket that has never had
versioning gets it turned on. Re-enabling a suspended bucket is not reported —
the user already opted in once, and its retained versions never went away.
The event carries `hasLifecyclePolicy` so we can see whether people pair
versioning with a retention policy or let noncurrent versions accumulate.
Not reported through `storage_bucket_created`: that event means a bucket was
created, and firing it from the edit modal would inflate bucket-creation counts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Mounts the versioning form section in the create and edit bucket modals behind
`useIsStorageVersioningEnabled`. Form state only for now — the Storage API does
not accept the fields yet, and both call sites are marked with TODOs.
- Both modals spread `bucketVersioningFormFields` into their schema and call
`superRefineBucketVersioning` from `superRefine`
- Create prefills the project defaults (30 days / 10 versions)
- Edit adds a confirmation before suspending an actively versioning bucket, and
derives its prefill through `getVersioningFormDefaults`
- `EditBucketModal.utils.ts` replaces the prefill and next-state derivations
with pure, tested helpers instead of stacked nested ternaries
- `storage_bucket_created` now reports whether versioning was enabled
Both forms move to `mode: 'onChange'` so the numeric versioning bounds surface as
the user types rather than only on submit. This also replaces the prototype's
`trigger()`-inside-`onChange`, which validated against pre-update form state.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
An all-nulls policy is what the form gets both from a bucket with no policy
and from a request that failed, so a failed fetch read as "no policy" and
invited a save over rules nobody had seen. Surface the error in the section
instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
The fields only represent a whole-bucket noncurrent-expiration policy of one
or two rules, so a bucket configured through the S3 or Storage API can hold
rules they cannot show. Render the policy as read-only in that case rather
than inviting an edit that would replace it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
A bucket whose stored policy has not arrived yet reads as having none,
because an unfetched policy and an absent one are both all-nulls. The
section can now say it is loading instead, and the tightening warning
treats an unread bound as unknown rather than unbounded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
The switch's id was hyphenated while the form layout labels by field
name, so the label pointed at nothing: unclickable, and no accessible
name. An explicit aria-label keeps the Private Alpha badge out of what
screen readers announce.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
An enabled bucket with no bound keeps noncurrent versions forever, so
introducing one expires everything past it on save. That went unwarned
because the check required a previous bound to compare against. An
undefined bound still means loading, and stays excluded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
Cuts the multi-paragraph comments down to one line each, or drops them
where the function or variable name already says it.
Also badges the "Object versioning" field "Private Alpha", in both
bucket modals — the label belongs to the shared field component.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
`FormInputGroupInput` already points the input's id at the form item, and
`FormLayout`'s react-form branch labels against that same id. The explicit
`id={field.name}` overrode it, so neither retention input had an
accessible name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
Three gates, each answering a different question.
`storageVersioningPrivateAlpha` in ConfigCat is our lever over the
dashboard UI — it decides whether any of this is reachable, and can be
pulled without a deploy. It also gates whether the feature preview is
listed at all.
`GET /platform/projects/{ref}/config/storage` now reports
`capabilities.object_versioning`, which says whether Storage has turned
versioning on for this project. During the private alpha that is granted
per project by the Storage team.
The feature preview opt-in stays on top of both, so an enabled project
doesn't change under users who never asked for it.
`getBucketVersioningState` now reads the bucket's real `versioning_status`
rather than reporting `disabled` for everything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
One term for the state a version is in, across the form copy, the version
history rows, and the `VersionFate` union.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The object versioning and lifecycle policy form section for the create and edit
bucket modals. Not mounted anywhere yet — the next PR wires it up — so this
lands as a self-contained, reviewable unit.
- `BucketVersioningFields` — the versioning switch and the suspension /
public-bucket / retention-tightening warnings
- `LifecyclePolicySection` — the retention window and version cap inputs
- `ExpirationModeToggle` — how the two conditions combine (and / or)
- `BucketVersioningFields.schema.ts` — zod fields the parent modals spread into
their own schema, plus `superRefineBucketVersioning`
- `BucketVersioningFields.utils.ts` — retention-tightening detection
- `StorageVersioning.constants.ts` — versioning state and expiration mode types,
the prefill defaults, and `getBucketVersioningState`
- `FormSectionCollapse` — shared height/opacity transition so sections revealed
by a toggle slide in rather than snap
The version cap requires an expiration age: S3 only accepts a noncurrent-count
condition alongside a noncurrent-days one. The input is disabled and dimmed until
an age is set, an orphaned cap is cleared when the age is removed, and the schema
enforces it as a backstop.
No plan gating: object versioning is available to every plan for the private
alpha, so the only numeric bound is the hard S3 ceiling of 100 noncurrent
versions per rule.
Both number fields use `''` as the in-form empty sentinel so they stay
controlled and deletable; empty means the condition is not part of the policy,
which is distinct from zero.
`getBucketVersioningState` is a stub returning `disabled` for every bucket. It is
the single seam that becomes `bucket.versioning` once the Storage API exposes it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
## Problem
Adds a new topic b/c `Comparisons` is reserved for other types of
content.
## Preview links
TBD
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Made the Comparison topic visible and added a hidden Rundowns topic
covering comparisons across technologies and facets.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary
The "> 8 GB warning" when spend cap enabled used to be conflated with
the "has replicas with spend cap" warning, which causes a confusing
error message.
Opting to split them out into 2 separate warnings to give the user a
better description of the problem.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Read replicas alone no longer trigger the disk-size threshold warning.
* **New Features**
* When usage billing is disabled, a warning appears if read replicas are
present and no project exceeds 8 GB, with guidance on next steps.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Problem
We were using a custom segment control. Recently we introduced segmented
toggle groups in our design system. The old one is inconsistent and
doesn't match anything else.
## Solution
Replace segmented control with [this
one](https://supabase.com/design-system/docs/components/toggle-group#segmented).
| Before | After |
|--------|--------|
| <img width="777" height="85" alt="Screenshot 2026-10-01 at 11 36 47"
src="https://github.com/user-attachments/assets/84e28075-248d-42d4-a537-f18cd2fe86db"
/> | <img width="783" height="95" alt="Screenshot 2026-10-01 at 11 37
00"
src="https://github.com/user-attachments/assets/1071f031-8e96-4db1-8ea1-36cb34e9923a"
/> |
## Test plan
- [ ] Go to Account Settings → Access Tokens, create a new scoped token,
and on the capability review step confirm the All/Read/Read-write
segmented control renders correctly and filters the capability list as
expected
- [ ] Open an existing scoped token's "View" sheet and confirm the same
segmented control filters correctly there too
- [ ] Verify keyboard navigation (arrow keys) and that exactly one
option is always selected (no deselect state)
- [ ] Visual check against the design system's segmented `ToggleGroup`
styling (no leftover custom border/divider artifacts from the old
implementation)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Style**
* Updated the capability-level selector to use a segmented control.
Selection behavior remains unchanged.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->