Commit Graph
38980 Commits
Author SHA1 Message Date
Claude a2096bdbf9 chore(storage): hold knip off the archived mutations until their consumers land
Each is reported as an unused file on this PR only; the rows and preview pane
that call them come later in the stack, and those PRs drop the entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 16:04:38 +00:00
Claude 9be04ceed6 test(storage): type the archived objects fixture instead of casting to any
Same treatment as the object versions fixture: the row type comes from
the function under test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 16:04:37 +00:00
Claude 74c32ca610 feat(storage): carry an archived version's mime type through the query
The API returns it and the mapper dropped it, so nothing downstream could
tell an image from a PDF. The archived preview needs it to render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 16:04:37 +00:00
Claude 252fccb9f1 fix(storage): hide the empty folder placeholder from archived rows
The live listing filters it out of every folder, but the archived overlay
was rendering it as a file. It still rolls its folder up, so an archived
empty folder stays visible rather than exposing the placeholder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 16:04:37 +00:00
Claude a11cd724d3 refactor(storage): trim comments to one line where they earn their place
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 16:04:37 +00:00
Claude 8af7eb00bf feat(storage): fetch and mutate archived objects against Storage
An archived object is one whose top row is a delete marker: gone from the
live listing, versions still retained. `list-v2` reports those rows once
`deleteMarkers` and `noncurrentVersions` are included, so the archived
list is a grouping of that listing rather than a dedicated endpoint.

The flat, undelimited listing is deliberate — the inline overlay
synthesizes folders from full paths, so it needs the whole bucket, not one
level at a time. Paging is bounded.

- Restoring deletes the delete marker, which promotes the version beneath
  it back to current; nothing is copied
- Purging deletes every retained version and the marker, since leaving the
  marker would keep the object listed as archived with nothing under it
- Deleting one retained version addresses it by `{ path, versionId }`

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 16:04:37 +00:00
Francesco SansalvadoreandClaude Sonnet 5 a41c9140bc feat(storage): add archived objects data layer
On a versioned bucket a delete is a soft delete, and the file preview panel
already calls that action Archive and promises the versions stay recoverable.
Nothing in the dashboard lets a user see or restore an archived file yet. This is
the data layer for that, with no UI: query and mutation shapes written to the
studio conventions, endpoints stubbed, returning empty.

- `archived-objects-query.ts` — `ArchivedObject` / `ArchivedObjectVersion` types
  and `archivedObjectsQueryOptions`
- `archived-object-restore-mutation.ts` — bring an archived object back
- `archived-object-purge-mutation.ts` — delete it and every version, permanently
- `archived-object-version-delete-mutation.ts` — remove one retained version
- `archivedOverlay.utils.ts` — synthesizes the explorer rows for one folder from
  the archived list
- `archivedVersions.utils.ts` — an archived object's history as one flat list

Two prototype problems fixed rather than carried over:

The prototype identified the "was current when archived" row by the sentinel
`versionId === objectId`, which was load-bearing across three files and would
break the moment real version ids arrived. `ArchivedObject` now carries a
`currentVersion` record, so merging invents no fields and the distinction is an
explicit `wasCurrentAtArchive` flag.

The prototype's object had both `name` and `originalPath`, inconsistently — the
path normalization existed mostly to strip a duplicated leaf folder that
inconsistency produced. There is now a single `path`, and `getArchivedSegments`
is the only function that interprets it, so a different API shape is a one-place
change.

Also drops `deletedBy` and `expiresAt`, which the prototype never rendered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-05 16:04:37 +00:00
Claude 59f15de1e3 refactor(storage): trim comments to one line where they earn their place
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 16:03:51 +00:00
Francesco SansalvadoreandClaude Sonnet 5 8a5bbf949b fix(storage): restore the pointer to where retention is configured
The breakdown is the only place on the org usage page that tells a user how to
act on the noncurrent bytes they're being billed for, and the navigation path had
been trimmed out of it.

Also flags why the per-bucket rows aren't links: buckets are per-project and this
is an org-level page, so the endpoint needs to return a project ref before a row
can be linked — or even disambiguated, since two projects can both have an
`avatars` bucket.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-05 16:03:51 +00:00
Francesco Sansalvadore bbd7cba64b improve comments 2026-10-05 16:03:50 +00:00
Francesco SansalvadoreandClaude Sonnet 5 1e86df2adc feat(storage): break down retained storage on the org usage page
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>
2026-10-05 16:03:34 +00:00
Claude 520e98abf3 refactor(storage): read the file URL key from the storage keys module
Follows the key's move out of the component module, so the explorer state and
the preview test both address it through the same factory.

Also return the signed URL through `HttpResponse.json` with its response type,
which is what the mock-API convention asks of a success response.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 16:03:13 +00:00
Claude 9974637056 fix(storage): purge the path the row was targeted with
Rebuilding it from the opened folders reads state that can have moved on since
the row was picked. Rows synthesized without a path still fall back to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 16:01:02 +00:00
Claude da3a5e40d8 fix(storage): clear the compare widget when its version stops existing
Restoring a version makes it the current one, and permanently deleting it
removes it, but the widget kept offering to restore either — in the delete case
pointing at a version that is gone.

Thumbnails and the widget's older half now classify bytes by the version's own
mime type rather than the file's current one, which an overwrite can change.
That makes the history's thumbnails real images, so its test needs the bucket
they resolve through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 16:01:01 +00:00
Claude dc37cb39cc feat(storage): replace a file from the preview panel
Overwrites an object in place, which on a versioned bucket is what creates a new
version. Not `uploadFiles`, which renames rather than overwrites on a name
clash, and with upsert set so storage does not reject the path as a conflict.

The panel's own preview URL is cached for a week against the path, so the
replace invalidates it alongside the version list; without that the panel keeps
rendering the bytes from before. A rejected write reports why where storage
answers with a bare status and no message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 16:01:01 +00:00
Claude 71406d7b3a fix(storage): keep the preview panel's URL button and row handles as they were
The redesign renamed the panel's "Get URL" button to "Copy URL" while the row
context menu kept "Get URL", leaving one action with two names, and gave the
panel's file name a `title` — the attribute the explorer rows use as their
handle, so `getByTitle` matched two elements once a preview was open.

Also snap five off-token sizes to the scale the ratchet enforces, and point the
E2E delete helper at the confirmation's real label now that it says what it
does instead of ConfirmationModal's "Submit" default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 16:01:01 +00:00
Claude 3abf1e3c79 fix(storage): say the version matched the policy and will be deleted
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
2026-10-05 16:01:01 +00:00
Claude a3f9af4af2 fix(storage): say cleanup can take up to 24 hours
There is no computable expiry timestamp: cleanup runs at no fixed moment
and removal can lag by a day, so the tooltip says so rather than implying
the next pass is imminent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 16:01:01 +00:00
Claude 4c67a5ad65 feat(storage): say a version is queued to expire, not expiring now
"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
2026-10-05 16:01:01 +00:00
Claude ab65dda355 fix(storage): refresh the row after restoring a version
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
2026-10-05 16:01:01 +00:00
Claude 237197a767 refactor(storage): trim comments to one line where they earn their place
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 16:01:00 +00:00
Claude 55273e29f8 fix(storage): preview a version's own bytes, not the current ones
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
2026-10-05 16:01:00 +00:00
Claude 37102cec47 feat(storage): offer Archive and Delete permanently from the row menu
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
2026-10-05 16:01:00 +00:00
Claude ae81279cbd feat(storage): drive version history off the real endpoints
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
2026-10-05 16:01:00 +00:00
Francesco SansalvadoreandClaude Sonnet 5 4608c6d5e5 fix(storage): tell the truth in the empty version history
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>
2026-10-05 16:01:00 +00:00
Francesco Sansalvadore eafbf4eb7e improve comments 2026-10-05 16:01:00 +00:00
Francesco SansalvadoreandClaude Sonnet 5 198d6ca47d refactor(storage): drop the unreachable cap-only lifecycle branch
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>
2026-10-05 16:01:00 +00:00
Francesco SansalvadoreandClaude Sonnet 5 6b3a67ff6b refactor(storage): say "retained" rather than "kept" for versions
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>
2026-10-05 16:01:00 +00:00
Francesco SansalvadoreandClaude Sonnet 5 be572f85ad feat(storage): add version history to the file preview panel
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>
2026-10-05 16:00:59 +00:00
Claude 318f600477 refactor(storage): move the file URL key in with the other storage keys
It is the one query key in this domain that lived in a component module, so the
state layer had to reach into the UI to invalidate a file's URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 16:00:52 +00:00
Claude d23910c73a fix(storage): keep each version's own mime type
An overwrite can change an object's type, so labelling every version with the
current file's would have consumers classify historical bytes by what the file
happens to be now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 16:00:52 +00:00
Claude 28235ca7ab fix(storage): purge every version, not just the first page
The list endpoint caps at 1000 rows, so an object with a longer history had the
first page deleted and the purge reported as done, leaving the rest behind with
the row already gone from the explorer. Deleting now repeats until the history
is empty, and gives up with an error rather than spinning if a round frees
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 15:14:39 +00:00
Claude 6e78f3cc5e feat(storage): give the file URL query a key factory
A replace has to invalidate the preview URL it just made stale, and the key was
built inline at its only call site. Extracting it lets a writer address the same
entry without restating the shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 15:14:38 +00:00
Claude 704429049f fix(storage): drop the lucide marker classes from the inline icon
Tailwind does not know `lucide`/`lucide-broom-sparkles`, so the ratchet
counted two new `shadcn/no-unknown-classes` violations. Nothing styles or
queries them — they only mirrored what lucide-react stamps on its own icons.

Also suppress knip's unused-file report for this icon and the three version
mutations: their first consumer is the preview panel in the next PR, which
removes the entries again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 13:38:28 +00:00
Claude cdc4135dd6 test(storage): type the object versions fixture instead of casting to any
Derives the row type from the function under test, so the fixture has to
stay in step with the shape the mapper actually reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 15:04:13 +02:00
Claude b032216f75 fix(storage): honor the cap rule's day floor before calling a version due
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
2026-10-05 15:04:13 +02:00
Claude be4f39fab5 refactor(storage): trim comments to one line where they earn their place
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 15:04:13 +02:00
Claude 245398e235 feat(storage): fetch and mutate object versions against Storage
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
2026-10-05 15:04:13 +02:00
Francesco Sansalvadore b949ad70f4 improve comments 2026-10-05 15:04:13 +02:00
Francesco SansalvadoreandClaude Sonnet 5 1f98b65111 refactor(storage): drop the unreachable cap-only lifecycle branch
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>
2026-10-05 15:04:13 +02:00
Francesco SansalvadoreandClaude Sonnet 5 e724309901 refactor(storage): say "retained" rather than "kept" for versions
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>
2026-10-05 15:04:13 +02:00
Francesco Sansalvadore 8c5f431a33 remove redundant test comment 2026-10-05 15:04:13 +02:00
Francesco SansalvadoreandClaude Sonnet 5 3f2b99335a feat(storage): add object versions data layer
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>
2026-10-05 15:04:13 +02:00
Claude 2097d4e8ae fix(storage): hold the save when the lifecycle policy could not be read
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
2026-10-05 15:04:12 +02:00
Claude a747fa1eb4 fix(storage): stop a save from replacing lifecycle rules it never read
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
2026-10-05 15:04:12 +02:00
Claude a439c32379 fix(storage): don't claim a bucket has no lifecycle policy while it loads
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
2026-10-05 15:04:12 +02:00
Claude 20ff11f95e refactor(storage): trim comments to one line where they earn their place
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
2026-10-05 15:04:12 +02:00
Claude df426b8ecd fix(storage): refresh the bucket list after an update or a delete
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
2026-10-05 15:04:12 +02:00
Claude 56e5e40884 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
2026-10-05 15:04:12 +02:00
Francesco SansalvadoreandClaude Sonnet 5 497869c909 fix(storage): keep the suspend confirmation up while the save runs
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>
2026-10-05 15:04:12 +02:00