# Storage

> Typed bucket paths, generated policies, and the upload flows apps keep rewriting.

Source: https://bettersupabase.com/docs/platform/storage

`defineBucket` turns a path template into typed path builders, bucket limits,
`storage.objects` policies and a `config.toml` section. Its client wraps
supabase-js Storage calls in the same `Result` as the repository.

```ts title="lib/buckets.ts"
import { defineBucket } from "better-supabase/storage";

export const logos = defineBucket({
  id: "customer-logos",
  path: "{organizationId}/{customerId}/logo/{version}.webp",
  policy: "tenant",
  fileSizeLimit: "5MiB",
  allowedMimeTypes: ["image/png", "image/jpeg", "image/webp"],
});
```

Or declare it in `better-supabase.config.ts` under `buckets` and use the
generated constant: `defineBucket(buckets.customerLogos)`. The path values
stay typed either way.

## Paths [#paths]

```ts
logos.path({ organizationId, customerId, version: 3 }); // { ok: true, data: 'o1/c1/logo/3.webp' }
logos.match("o1/c1/logo/3.webp"); // { organizationId: 'o1', customerId: 'c1', version: '3' }
logos.prefix({ organizationId }); // 'o1', the folder to list
```

Missing values are type errors. At runtime every value is checked: it can't
be empty, `.` or `..`, contain `/`, or use characters Storage rejects. A
failed check is an `invalid_input` error, and no request is sent. `path()`
returns a `Result` like every other bucket method, so a value from user input
never throws:

```ts
const path = logos.path({ organizationId, customerId, version });
if (!path.ok) return problemResponse(path.error); // invalid_input, 400
```

Client methods accept values or an existing path string, and a path string
must match the template.

### Several layouts [#several-layouts]

A bucket often holds objects in more than one shape: file versions,
exports and attachments side by side, and paths written by an older layout
that rows still reference. Pass `path` an array of templates. A path from
values uses the template whose placeholders are exactly the keys you give,
and a stored path string is accepted when any template matches it, so put
the current layout first and keep older ones after it:

```ts title="lib/buckets.ts"
export const files = defineBucket({
  id: "files",
  path: [
    "{organizationId}/files/{fileId}/v{version}.{ext}",
    "{organizationId}/exports/{exportId}.zip",
    "{organizationId}/{...rest}",
  ],
  policy: "tenant",
  tenant: {},
});

files.path({ organizationId, exportId }).data; // 'o1/exports/e1.zip'
files.match("o1/2023/report.pdf"); // { organizationId: 'o1', rest: '2023/report.pdf' }
```

A last segment written as `{...rest}` matches one or more segments, for
stored paths whose shape varies. Each of its segments is checked like any
other value: no empty segments, no `.` or `..`, and only characters Storage
accepts. A path with a leading `/` or outside the templates is refused.

Every template must hold the tenant placeholder, and a `tenant`, `owner` or
permission policy needs its placeholder at the same segment in every
template, so the generated policies and the connected client check one
segment for all of them. The write policies accept a name that matches any
template. `prefix(values)` returns the folder that every template holding
those values shares.

### Path columns [#path-columns]

`logos.path()` (inside its `Result`) and `upload()` return a
`StoragePath<'customer-logos'>`: a string branded with the bucket id. Client methods take plain strings too, but
reject a path branded for another bucket.

Store that path in the row, never a URL. Signed URLs expire and public URLs
pin the project host, so build the URL when you render. List the column in
`storagePaths` and `gen` types it:

```ts title="better-supabase.config.ts"
export default defineConfig({
  buckets: {
    customerLogos: {
      path: "{organizationId}/{customerId}/logo/{version}.webp",
      policy: "tenant",
    },
  },
  storagePaths: { "customers.logo_path": "customerLogos" }, // a buckets key or a bucket id
});
```

```ts
customer.logoPath; // StoragePath<'customer-logos'> | null
await db.customers.update(id, { logoPath: "o1/c1/logo/3.webp" }); // type error: not a path of that bucket
await db.customers.update(id, {
  logoPath: (await storage.upload(values, file).orThrow()).path,
}); // ok
```

`gen` fails on a key that names no column or a column that isn't text. The
[`storagePathColumns` rule](/docs/plugins/rules) warns when a Storage URL or
bucket path is written to a `*_url` column.

### Rows that store a bucket id [#rows-that-store-a-bucket-id]

Tables for files, attachments or knowledge sources often keep the bucket id
next to the path, because their objects live in more than one bucket.
`defineBuckets` registers the bucket definitions by id, so a stored
`{ bucket, path }` reaches its definition without a map of your own:

```ts title="lib/buckets.ts"
import { defineBuckets } from "better-supabase/storage";

export const buckets = defineBuckets({ files, attachments, sources });
```

```ts
const ref = buckets.connectStored(
  supabase,
  { bucket: row.bucketId, path: row.path },
  { context: db.$context },
);
if (!ref.ok) return problemResponse(ref.error);
const url = await ref.data.storage.signedUrl(ref.data.path).orThrow();
```

| Method                                | Returns                                                                                     |
| ------------------------------------- | ------------------------------------------------------------------------------------------- |
| `byId(id)`                            | The bucket definition, as a `Result`                                                        |
| `has(id)`                             | Whether a bucket has that id, as a type guard                                               |
| `resolve({ bucket, path })`           | The definition and the branded `StoragePath`, after checking the path against its templates |
| `connectStored(client, ref, options)` | `resolve`, plus a connected client that has checked the path, tenant included               |
| `ids`, `buckets`                      | The registered ids, and the definitions by the names you gave                               |

The registry fails closed. An id no bucket has is an `invalid_input` error,
never a default bucket, and so is a path outside the bucket's templates.
`connectStored` takes the same options as `connect()`, so a bucket with
`tenant` refuses another tenant's path with `forbidden`. None of these
methods sends a request. Two buckets with the same id throw when the
registry is defined.

## Policies and config [#policies-and-config]

`logos.sql()` returns idempotent SQL. It upserts the bucket row and creates
`storage.objects` policies for the `authenticated` role:

| `policy`     | Access                                                                                                                                     |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `tenant`     | The `{organizationId}` segment must equal the JWT `tenant_id` (or `app_metadata.tenant_id`)                                                |
| `owner`      | The `{userId}` segment must equal `auth.uid()`                                                                                             |
| `public`     | Anyone can read. Writes need the secret key. A `public: true` bucket gets no select policy, so its public URLs work and nobody can list it |
| `none`       | No policies. Only the secret key has access                                                                                                |
| `{ access }` | The SQL modules' access contract decides, for any access model (see below)                                                                 |

`bucket.owner` names the placeholder that holds the owning user's id: the
`owner` param for `owner` buckets, otherwise `userId` when the template has
it. [`deleteAccount`](/docs/auth/account-deletion) removes the objects it
fills.

Writes are also checked against the template's shape
(`name ~ '^[^/]+/[^/]+/logo/[^/]+\.webp$'`), so users can't upload objects
outside the paths your code builds. Use `tenant: { param, claim }` or
`tenant: { sql }` to change which JWT claim or SQL expression is compared.

When the `tenant` or `owner` segment comes first in the template, the
policies also bound `name` to that segment's prefix in the `"C"` collation,
so Postgres reads one tenant's objects through the storage name index instead
of scanning the bucket. Put the tenant first in new templates to get this.

### Access contract permissions [#access-contract-permissions]

With the [access contract](/docs/blocks/access) installed, a bucket can check
the app's permissions whatever model backs them: default roles, your own
role tables, an authorization provider or your own functions. The tenant id in the path must
be one of `tenant_ids_with(key)`, or `scope: 'platform'` checks
`is_platform(key)`:

```ts title="src/lib/storage.ts"
export const contracts = defineBucket({
  id: "contracts",
  path: "{organizationId}/{contractId}/{file}",
  policy: {
    access: { read: "contracts.read", write: "contracts.write" },
  },
  tenant: { param: "organizationId" },
});
```

`list` and `delete` take their own keys (see
[provider permissions](#provider-permissions)), and `segment` picks another
path segment for the tenant id.

### Avatars and organization logos [#avatars-and-organization-logos]

`avatarBucket()` and `organizationLogoBucket()` define the two buckets most apps
need. Both are public, take 2 MiB of images (`IMAGE_TYPES`) and accept
`id`, `path`, `public`, `fileSizeLimit`, `allowedMimeTypes` and `policy`:

| Preset                   | Path                                    | Who writes                                                      |
| ------------------------ | --------------------------------------- | --------------------------------------------------------------- |
| `avatarBucket`           | `{userId}/avatar-{version}.{ext}`       | the user (`owner` policy)                                       |
| `organizationLogoBucket` | `{organizationId}/logo-{version}.{ext}` | members with `organization.update`, through the access contract |

```ts title="src/lib/storage.ts"
import { avatarBucket, organizationLogoBucket } from "better-supabase/storage";

export const avatars = avatarBucket();
export const logos = organizationLogoBucket({
  permission: "organization.branding",
});
```

`organizationLogoBucket` holds connected clients to the `{organizationId}` of their tenant;
pass `tenant: false` to upload for any organization the policy allows. A new
`{version}` per upload keeps CDN caches correct; store the path in the
profile's `avatar_url` or the organization's logo column.

### Provider permissions [#provider-permissions]

With an [authorization provider](/docs/extending/authorization-providers), an
`access` policy can call the provider's SQL functions directly, for any scope
they take, instead of the access contract. `sql: "provider"` uses the
config's `authorization.functions`, which `better-supabase gen` writes into the
generated bucket policies. An object with `idsWith` and `isPlatform` copies
the templates instead; doctor (BS214) warns when the copy differs from the
provider.

```ts title="src/lib/storage.ts"
export const files = defineBucket({
  id: "organization-files",
  path: "{organizationId}/{file}",
  policy: {
    access: {
      read: "files.download",
      list: "files.browse",
      write: "files.upload",
    },
    scope: "organization", // or 'platform'
    sql: "provider",
  },
});
```

`read` covers downloads, signed URLs, image renders and metadata reads.
With `list`, listing gets its own policy (through
`storage.allow_any_operation`), so a user can download a file they were sent
without being able to browse the folder, or the other way around. `write`
covers uploads, updates and moves, and deletes unless `delete` is set. The
scope id comes from the `{organizationId}` segment; set `segment` (1-based) for
another one. The id is compared as text, so a malformed path is denied instead
of raising a cast error, and the segment must be the id's canonical lowercase
form: a path with an uppercase uuid is denied.

The policies are only ever for `authenticated`.

#### Only for permissions without row conditions [#only-for-permissions-without-row-conditions]

The policies check role and scope, nothing else. A permission's row
conditions (for example "only files the member uploaded") are applied by the
provider's table policies, and its functions called on their own ignore them.
For a permission with row conditions, the bucket would grant every object in
the scope.

This bucket is wrong if `files.download` is granted to members only for files
they own: every member can download every file in the organization.

```ts title="src/lib/storage.ts"
// Wrong: files.download has a row condition, which the policy ignores
policy: { access: { read: "files.download", write: "files.upload" }, scope: "organization", sql },
// Right: files.browse is granted per organization, with no row condition
policy: { access: { read: "files.browse", write: "files.upload" }, scope: "organization", sql },
```

Keep row-conditioned permissions on tables, where the provider's policies
enforce them. The provider marks the keys its functions answer completely
with `sqlComplete: true`. For buckets in `better-supabase.config.ts`,
`better-supabase gen` refuses to write policies for any other key, and
[`doctor`](/docs/cli/doctor#bs214) reports BS214 for them and for generated
bucket policies in your SQL files.

`logos.toml()` gives the `[storage.buckets.customer-logos]` section for
`supabase/config.toml`. `logos.drift(actual)` compares the bucket with what
the database has. [`doctor`](/docs/cli) reports those differences.

## Client [#client]

```ts
const storage = logos.connect(supabase); // user client: policies apply

await storage.upload({ organizationId, customerId, version }, file).orThrow();
const url = await storage
  .signedUrl({ organizationId, customerId, version }, { ttl: "hour" })
  .orThrow();
const thumb = await storage
  .renderUrl(path, { width: 128, height: 128, resize: "cover" })
  .orThrow();
```

`publicUrl(target)` builds the URL of an object in a public bucket without a
request. Like `path(target)`, it returns a `Result`: `invalid_input` for a
path outside the templates and `forbidden` for another tenant's path.

```ts
const logo = storage.publicUrl(customer.logoPath);
const src = logo.ok ? logo.data : fallbackLogo;
```

Uploads are checked against `fileSizeLimit` and `allowedMimeTypes` before any
request. Failures become `DbError`s: `invalid_input` with status 413 or 415,
`forbidden` for policy denials, `conflict` for existing objects, and `network`
for 5xx responses. TTL presets are `minute`, `hour`, `day` and `week`, or pass
a number of seconds.

`copy(from, to)` and `move(from, to)` copy or move an object to another path
in the same bucket in one Storage request, and return the new path. Both
paths are checked against the template (and the tenant, below) first; an
object already at `to` is a `conflict`:

```ts
await storage
  .copy(
    { organizationId, customerId, version: "v1" },
    { organizationId, customerId, version: "v2" },
  )
  .orThrow();
```

A page that renders the same object many times can reuse its signed URL:

```ts
const storage = logos.connect(supabase, { cacheSignedUrls: true });
```

The connection then signs each path, `ttl`, `transform` and `download`
combination once and reuses the URL until a tenth of its `ttl` (at most a
minute) remains. It keeps at most 500 URLs and drops the oldest first. The
cache lives on that connection only, so connect once per request and URLs
signed for one user never reach another.

### Tenant scope [#tenant-scope]

Setting `tenant` on the bucket also makes connected clients check paths
before they reach Storage, which matters for the secret-key client that
policies don't limit. Every path the client builds, accepts, signs, uploads,
copies, moves, removes or reserves must hold the caller's tenant in the `tenant.param`
segment (`{organizationId}` by default), and `list()` lists only under it:

```ts
const logos = defineBucket({
  id: "customer-logos",
  path: "{organizationId}/{customerId}/logo/{version}.webp",
  policy: "tenant",
  tenant: { claim: "tenant_id" },
});

const storage = logos.connect(supabase, { context: db.$context });
await storage.upload(
  { organizationId: otherOrganization, customerId, version },
  file,
);
// { ok: false, error: { kind: "forbidden" } }, no request made
```

The tenant comes from `{ tenant }`, else from `context`: `context.tenant`
(which the `tenant()` plugin fills from its claims in `db.$context`), then
the `tenant.claim` claims. A
connection without a tenant gets `forbidden` for every call. Cross-tenant
admin work (account deletion does this) connects with `{ allTenants: true }`.
`storage.path(target)` and `publicUrl()`, which make no request, return
the checked path or URL as a `Result`, with the `forbidden` error for
another tenant's path.

### Image transformations [#image-transformations]

`renderUrl(target, { width, height, resize, quality })` returns a
[Storage image transformation](https://supabase.com/docs/guides/storage/serving/image-transformations)
URL. For public buckets it builds `/storage/v1/render/image/public/...` without
a request. For private buckets it signs one, and Storage answers with a
`/storage/v1/render/image/sign/...` URL whose token covers the transform, so
sign at the size you render.

With image transformations off (the Free plan, or a local stack without
`[storage.image_transformation] enabled = true`), Storage signs a plain
`/storage/v1/object/sign/...` URL instead, and the original image is served.

### next/image [#nextimage]

`better-supabase/next/image` is a [`loaderFile`](https://nextjs.org/docs/app/api-reference/components/image#loaderfile)
that serves public objects through Storage transformations instead of the
Next.js optimizer:

```ts title="src/image-loader.ts"
import { createImageLoader } from "better-supabase/next/image";

export default createImageLoader({
  url: process.env.NEXT_PUBLIC_SUPABASE_URL!,
});
```

```ts title="next.config.ts"
export default {
  images: { loader: "custom", loaderFile: "./src/image-loader.ts" },
};
```

The loader rewrites `/object/public/` and `/render/image/public/` URLs of your
project to `/render/image/public/` with the requested `width` (at most 2500)
and `quality` (20 to 100), and sets `resize` when you pass one. Signed URLs
pass through unchanged, since changing their parameters would break the
token. Local and other hosts' images go to `fallback`, which returns `src`
unchanged by default. Next.js warns in development when a loader ignores
`width`, so give those images `unoptimized` or a `fallback`.

### Replace [#replace]

Swapping an avatar or logo takes three steps, and each can fail: upload the
new file, point the row at it, delete the old file. `replace` runs them in
that order and cleans up after a failure:

```ts
const { path } = await storage
  .replace({ organizationId, customerId, version: crypto.randomUUID() }, file, {
    previous: customer.logoPath,
    commit: (path) => db.customers.update(customer.id, { logoPath: path }),
  })
  .orThrow();
```

`previous` is checked like any other target before the upload: a path
outside the templates is `invalid_input` and another tenant's path is
`forbidden`, so `replace` never removes an object the client could not
address. If `commit` throws or returns an error `Result`, the new object is
removed and the old one is kept. If removing the old object fails, the result is still ok,
with `cleanup` set to the error.

### Signed uploads [#signed-uploads]

Let the browser upload directly with a server-issued reservation:

```ts
// server
const reservation = await logos
  .connect(supabase)
  .reserve({ organizationId, customerId, version })
  .orThrow();
// browser
await logos.connect(supabase).uploadReserved(reservation, file).orThrow();
```

### Several files and progress [#several-files-and-progress]

The dropzone block in the Supabase library uploads every file again when one
fails and reports no byte progress. Since `upload` returns a `Result` per
file, keep the files that failed and retry only those:

```ts
const storage = files.connect(supabase); // path: "{organizationId}/{file}"
const results = await Promise.all(
  selected.map(async (file) => ({
    file,
    result: await storage.upload({ organizationId, file: file.name }, file),
  })),
);
const failed = results
  .filter(({ result }) => !result.ok)
  .map(({ file }) => file);
// show the errors, then call the same code with `failed` when the user retries
```

A retry can find the object already there when an earlier attempt reached
Storage but its response didn't reach the browser. That is a `conflict`; pass
`upsert: true` when overwriting is fine.

`upload` and `uploadReserved` send the file with `fetch`, which reports no
upload progress. For a progress bar, reserve the path on the server and send
the file to `reservation.signedUrl` with `XMLHttpRequest`:

```ts
const body = new FormData();
body.append("cacheControl", "3600");
body.append("", file);

const request = new XMLHttpRequest();
request.upload.onprogress = (event) => setProgress(event.loaded / event.total);
request.open("PUT", reservation.signedUrl);
request.send(body);
```

This skips the client-side size and type checks, so Storage's
`fileSizeLimit` and `allowedMimeTypes` are the ones that apply. For files
large enough to need resuming, use a TUS client against Storage's
[resumable uploads](https://supabase.com/docs/guides/storage/uploads/resumable-uploads)
endpoint. The [attachments block](/docs/blocks/attachments) records each
upload as a row and scans it before anyone can download it.

### Orphan sweep [#orphan-sweep]

Run it from a job to remove objects nothing references anymore:

```ts
const { removed } = await logos
  .connect(adminClient)
  .sweep({
    within: { organizationId },
    olderThan: Temporal.Duration.from({ hours: 24 }), // skip uploads that may still be committing
    referenced: async (paths) =>
      (
        await db.customers
          .findMany({
            where: { logoPath: { in: paths } },
            select: ["logoPath"],
          })
          .orThrow()
      ).map((row) => row.logoPath!),
  })
  .orThrow();
```

`olderThan` takes a `Temporal.Duration` (a day counts as 24 hours) or a cutoff
`Temporal.Instant`; see [Temporal](/docs/concepts/temporal).
Only objects that match a template and every `within` value are candidates,
also when a value sits after a placeholder `within` leaves out. Use `dryRun: true` to see
the `orphans` without deleting them.

## Versions and lifecycle [#versions-and-lifecycle]

A bucket with `versioning: true` keeps the earlier versions of an object when
it is overwritten or removed. `lifecycle` expires noncurrent versions, so it
needs `versioning`:

```ts title="src/lib/storage.ts"
export const contracts = defineBucket({
  id: "contracts",
  path: "{organizationId}/{contractId}.pdf",
  policy: "tenant",
  versioning: true,
  lifecycle: {
    rules: [
      {
        noncurrentVersionExpiration: {
          noncurrentDays: 90,
          newerNoncurrentVersions: 5,
        },
      },
    ],
  },
});
```

Storage keeps both settings behind its API, so `sql()` can't write them.
`apply(client)` creates or updates the bucket through the API with the secret
key, sets versioning, and sets the lifecycle policy (or removes a stored one
when `lifecycle` is unset). It is idempotent, so run it from a deploy script:

```ts
await contracts.apply(adminClient).orThrow();
```

Turning `versioning` off on a bucket that had it suspends versioning; Storage
never returns a bucket to `DISABLED`. A project where versioning or lifecycles
aren't enabled answers `unsupported`, and `apply` ignores that for the
lifecycle removal. `better-supabase doctor` (BS302) compares both settings
with `storage.buckets` when the Storage version has the columns.

The connected bucket reads and writes single versions:

```ts
const files = contracts.connect(adminClient);
const versions = await files.versions(path).orThrow(); // newest first
const previous = versions.find((version) => !version.current);
if (previous) {
  const pdf = await files
    .download(path, { versionId: previous.versionId })
    .orThrow();
  await files.copy(
    path,
    { organizationId, contractId: "restored" },
    { versionId: previous.versionId },
  );
  await files.removeVersions(path, [previous.versionId]);
}
```

`signedUrl` and `publicUrl` take `versionId` too. Each entry in `versions()`
has `versionId`, `current`, `deleteMarker`, `size`, `contentType`, `createdAt`
and `archivedAt`.

## CDN cache [#cdn-cache]

`cacheNonce` on `signedUrl`, `signedUrls`, `publicUrl` and `renderUrl` adds a
query parameter, so the CDN and browsers fetch the object again after it
changes. Pass something that changes with the object, such as its
`updatedAt`. `purgeCache(target)` invalidates the cached object on the CDN
instead; `{ transformations: true }` purges only the transformed variants. It
needs the secret key, and self-hosted Storage needs a configured CDN purge
endpoint.

## Vector buckets [#vector-buckets]

`defineVectorBucket` declares a Storage vector bucket and its indexes. Storage
keeps the vectors outside Postgres; for pgvector in a table, use the
[`vector-search` module](/docs/blocks/vector-search).

```ts title="src/lib/storage.ts"
import { defineVectorBucket } from "better-supabase/storage";

export const embeddings = defineVectorBucket({
  id: "embeddings",
  indexes: {
    documents: { dimension: 1536, distanceMetric: "cosine" },
  },
});
```

```ts
const vectors = embeddings.connect(adminClient);
await vectors.apply().orThrow(); // creates the bucket and missing indexes

const documents = vectors.index<{ organizationId: string }>("documents");
await documents
  .put([{ key: doc.id, vector, metadata: { organizationId } }])
  .orThrow();
const hits = await documents
  .query(queryVector, { topK: 5, filter: { organizationId } })
  .orThrow(); // [{ key, distance, metadata }], closest first
```

`put` sends batches of 500 and checks every vector's length against the
index's `dimension` first, so a wrong length is an `invalid_input` error
instead of a Storage server error. `apply` refuses an existing index with
another dimension or metric with `conflict`. `get(keys)` and `remove(keys)`
complete the set.

## Analytics buckets [#analytics-buckets]

`defineAnalyticsBucket({ id })` declares a Storage analytics bucket, which
holds Apache Iceberg tables. `connect(client).apply()` creates it when it is
missing, and `catalog()` returns its Iceberg REST catalog
(`storage.analytics.from(id)`) for namespaces and tables. A stack without
analytics buckets answers `unsupported`.

## Porting supabase.storage calls [#porting-supabasestorage-calls]

Each `supabase.storage.from(bucket)` call has a method on the connected
bucket that takes a template target or a path and returns a `Result`:

| supabase-js                             | Connected bucket                             |
| --------------------------------------- | -------------------------------------------- |
| `.upload(path, file, { upsert })`       | `upload(target, file, { upsert })`           |
| `.download(path)`                       | `download(target)`                           |
| `.remove([path])`                       | `remove([target])`                           |
| `.copy(from, to)` and `.move(from, to)` | `copy(from, to)` and `move(from, to)`        |
| `.exists(path)`                         | `exists(target)`, an error unless 400 or 404 |
| `.list(folder)`                         | `list(within)`, recursive and paginated      |
| `.createSignedUrl(path, ttl)`           | `signedUrl(target, { ttl })`                 |
| `.createSignedUrls(paths, ttl)`         | `signedUrls(targets, { ttl })`               |
| `.getPublicUrl(path)`                   | `publicUrl(target)`, a `Result`              |
| `.getPublicUrl(path, { transform })`    | `renderUrl(target, transform)`               |
| `.createSignedUploadUrl(path)`          | `reserve(target)`                            |
| `.uploadToSignedUrl(path, token, file)` | `uploadReserved(reservation, file)`          |
| `.download(path, { versionId })`        | `download(target, { versionId })`            |
| `.listV2({ noncurrentVersions })`       | `versions(target)`                           |
| `.remove([{ path, versionId }])`        | `removeVersions(target, [versionId])`        |
| `.purgeCache(path)`                     | `purgeCache(target)`                         |
| `storage.updateBucket`, lifecycle calls | `bucket.apply(client)`                       |
| `storage.vectors`                       | `defineVectorBucket`                         |
| `storage.analytics`                     | `defineAnalyticsBucket`                      |

`exists` is `false` only when Storage answers 400 or 404; a denied or failed
check is an error, not a missing file. `list` walks sibling folders four at
a time and returns objects in name order.

Store the path a call returns (`StoragePath`) in a `*_path` column and
resolve URLs when you read the row, so a URL never goes stale in the
database. Port one bucket at a time: `defineBucket` reads and writes the
same `storage.objects` rows, so code that still calls `supabase.storage`
keeps working on the objects the bucket writes.