---
changelog:
  category: Release
  version: files-sdk@1.9.0
date: '2026-06-14T20:00:42Z'
seo:
  description: >-
    Add an audit() plugin at files-sdk/audit that writes a structured
    who/what/when record of every mutation to an awaited sink — the durable,
    awaitable…
title: files-sdk@1.9.0
type: changelog
---
### Minor Changes

- ff814cc: Add an `audit()` plugin at `files-sdk/audit` that writes a structured who/what/when record of every mutation to an **awaited** sink — the durable, awaitable counterpart to the fire-and-forget `onAction` hook. Each audited operation produces one `AuditRecord` carrying the verb, the caller-facing key (or `from` / `to`), an optional `actor`, the start time and duration, the outcome, and — on a successful `upload` — the stored size. Because the sink is awaited, the operation doesn't resolve until the record is written, giving you ordering and back-pressure a hook can't: on a successful operation a rejecting sink fails the call (the mutation happened but wasn't recorded — fail closed), while on a failed operation the operation's own error always wins so a sink problem can never mask why the call failed. By default it records the mutating verbs (`upload`, `delete`, `copy`, `move`, `signedUploadUrl`); pass `events: "all"` to also audit reads, or an explicit list to record exactly the verbs you name. Resolve `actor` synchronously from your request context to attribute each record. It's body-transparent (never buffers, transforms, or reads the body — `size` comes from declared metadata), writes no object metadata, and has no native dependencies, so it works on any adapter. Plugins run outside retries (so a retried call is still one record) on caller-facing keys; bulk `upload([...])` / `delete([...])` fan out to one record per item, each flagged `bulk: true`. It's `wrap`-only, so plain `new Files({ plugins })` works. Place it first (outermost) so it records the caller's logical intent — a `delete` an inner `softDelete()` turns into a `move` is still audited as the `delete` the caller asked for.
- daca585: Add a `cache()` plugin at `files-sdk/cache` — an LRU/KV cache in front of the cheap read verbs. A repeat `head()` or `url()` (and, opt-in, a small `download()`) for an unchanged key is served from memory instead of round-tripping to the provider; any write through the instance (`upload`, `delete`, `copy`, `move`) invalidates the affected key so the next read re-fetches. `head` caches metadata only (a hit's body still lazy-fetches on access, matching the uncached `head` contract); `url` caches per url-options signature and **caps each entry at its own `expiresIn`** so a presigned URL is never handed out past its signature; `download` is off by default and, when enabled via `operations: ["download"]`, buffers only **known-length bodies at or under `maxBytes`** (default 1 MiB) so streaming and large objects keep working. Defaults to a bounded in-memory LRU (`maxEntries`, default 1000), or pass your own `CacheStore` to back it with a shared KV. Entries honor a `ttl` (default 60s; `0` disables time-based expiry). It writes **no object metadata** and has **no native dependencies**, so it works on any adapter, and runs **outside** retries so a hit skips the retry loop entirely. It uses `extend` for `invalidateCache(key?)`, `cacheStats()`, and `resetCacheStats()` — construct with `createFiles` to surface them on the type. Place it **first** (outermost) so a hit short-circuits before the rest of the pipeline does any work; writes made out-of-band (a presigned-URL upload, or a change straight against the provider) won't invalidate, so call `invalidateCache()` and treat the cache as eventually-consistent.
- 83d6eb4: Add a `failover()` plugin at `files-sdk/failover` that reads/writes the primary and falls back to one or more secondary adapters when a backend is down — a live, per-operation failover chain. The **primary** is the instance's own adapter (reached through the rest of the onion, so it keeps retry and prefixing); the **secondaries** are backup adapters passed in `secondaries` (a single `Adapter` or an array for a multi-region chain), each wrapped in its own internal `Files` so it gets the same retry, capability gating, and `StoredFile` normalization. Every verb runs the same way: try the primary; if it throws and `shouldFailover` says so, try the next backend, and so on — the first to succeed wins, and if the chain is exhausted the last error is thrown. The default predicate fails over **only** on `Provider` errors (network / timeout / 5xx — "the backend is down") and never on an aborted request or a definitive answer from a healthy backend (`NotFound`, `Unauthorized`, …), so a genuine 404 stays a 404 instead of being masked by a replica; pass your own `shouldFailover` to widen it (e.g. read through to a replica on `NotFound`) or narrow it. This is the **availability** counterpart to `tiering()` (which _partitions_ by key/size): failover treats each secondary as a full replica, so it never splits or merges across backends — `list` returns the first reachable backend's page (not a merged one), and writes land on the first reachable backend rather than fanning out to all (that's `replication()`). A streaming `upload` (a `ReadableStream` body) can't be replayed, so it runs against the primary alone and isn't failed over. An optional `onFailover` callback (fire-and-forget; a throw from it is swallowed) reports each fail over with the operation and the backend indices, for metrics / alerting. It's body-transparent, has no native dependencies, and adds no surface (`wrap` only), so it works with plain `new Files({ plugins })`. Place it last (innermost) so body-transforming plugins like `encryption()` wrap every backend, and give each secondary its own bucket / container (secondaries receive caller-facing keys, without the instance `prefix`). Failover buys availability, not convergence — reconcile a secondary written during an outage with `sync` / `transfer`, or keep it current with `replication()`.
- 581c97f: Add a queryable `files.capabilities` surface that reports what the underlying adapter can do, so callers, AI tool wrappers, and validators can branch up front instead of relying on a throw at call time. It returns an `AdapterCapabilities` snapshot with eight fields, each mirroring an operation the unified API actually exposes: `rangeRead`, `uploadProgress`, `delimiter`, `metadata`, `cacheControl`, and `multipart` are derived live from the same per-adapter flags and optional methods the wrapper already gates on (so they can never drift from runtime behavior), while `serverSideCopy` and `signedUrl` (`{ supported; maxExpiresIn? }`) are declared per-adapter and default to the conservative value when unset — a caller that doesn't advertise reads as "no", never a wrong "yes". `signedUrl.supported` is `true` when `url()` can mint a signed or tokenized URL (not just a permanent public link); `maxExpiresIn` is set only where a provider enforces a hard `expiresIn` ceiling in code (e.g. Dropbox's 4-hour temporary links), not for soft infra limits or config-dependent caps. Custom adapters can set the new optional `supportsServerSideCopy` and `signedUrl` fields alongside the existing `supports*` flags; both are advisory and gate nothing. See the new Capabilities and Provider gaps documentation.
- 81e0e64: Add a `neon` adapter at `files-sdk/neon` for [Neon](https://neon.com) branchable object storage over its S3-compatible API. A thin wrapper around the S3 adapter — errors relabelled, with path-style addressing on by default because Neon requires it (the wildcard TLS cert covers a single subdomain level, occupied by the branch id, so the bucket name travels in the request path). It reads the standard `AWS_*` variables that `neon dev` / `neon env pull` inject for the linked branch — `endpoint` from `AWS_ENDPOINT_URL_S3`, region from `AWS_REGION` (then `NEON_STORAGE_REGION`, then `us-east-1`), and credentials through the AWS SDK chain (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`) — so inside a Neon Function or after an env pull it works from env alone: `neon({ bucket: "images" })`. Catalogued in `files-sdk/providers` and exposed through the CLI.
- 2275982: Add an opt-in `receipts` option that surfaces a provenance `Receipt` for each mutating call (`upload`, `delete`, `copy`, `move`) — built for AI tool wrappers and agents that need to attest "this exact content landed at this key". It's **off by default**: an instance without the option records nothing and hashes nothing, so existing behavior is unchanged. Turn it on with `receipts: true` to attach a `Receipt` (`{ op, provider, key, bytes?, etag?, sha256?, durationMs, ts }`) to the success `onAction` event of each mutating call — an additive `receipt` field on the existing hook, with no new operation, callback, or changed return type. Every field except `sha256` is derived from the work the SDK already does for the hook (timing, the adapter name, the caller-facing key, and `bytes` / `etag` read straight off the `UploadResult`), so plain `receipts: true` adds no per-call cost. `sha256` is the one field with a real per-call cost and is opt-in by name: pass `receipts: { sha256: true }` to fingerprint the upload body as passed to `upload()` — taken before any plugin transform, so it matches what `download` gives back rather than the (possibly encrypted/compressed) bytes on disk — a lowercase-hex SHA-256, present only on an `upload` of a buffered body. A streaming upload is never buffered to hash it (so it carries no fingerprint), and `delete` / `copy` / `move` transfer no content of their own; with `sha256` off, the body is never read. Reads, `signedUploadUrl`, failures, bulk array calls, and receipts-off instances all leave `event.receipt` unset. See the new Receipts documentation.
- 5a77a58: Add a `signedUrlPolicy()` plugin at `files-sdk/signed-url-policy` that enforces safe defaults on the two URL-minting operations, turning the security caveats `url()` and `signedUploadUrl()` document into the default. On `url()` it forces a download `Content-Disposition` (default `"attachment"`, so user-uploaded HTML/SVG can't execute inline at your origin — the stored-XSS warning made a default) while preserving a caller's existing `attachment` (and its `filename`), and clamps `expiresIn` to `maxExpiresIn`. On `signedUploadUrl()` it clamps `expiresIn` to the same cap and, when `maxUploadSize` is set, guarantees a server-enforced `maxSize` is always present (injected when absent, clamped when over) — so an adapter that can't bind a size limit fails closed loudly instead of minting an unbounded URL. It writes no metadata, transforms nothing on disk, never throws of its own accord, and lets every other verb pass straight through; with no options set it still applies the headline default (`url()` forces `attachment`). Set `disposition: false` to opt out of the disposition guard. Place it first (outermost) so it sees the caller's original request and its rewritten options reach the signing adapter.
- ae58680: Add a `softDelete()` plugin at `files-sdk/soft-delete` that turns `delete` into a recoverable move into a trash prefix - a recycle bin for any adapter. Instead of destroying an object, a `delete` server-side moves it to `"<prefix>/<key>"` (`.trash/` by default); the bytes only leave storage when you `purge()`. It adds three methods via `extend` (so construct with `createFiles`): `trashed()` lists what's in the trash (each entry carries the original `key` plus a downloadable `trashKey`), `restore(key)` moves the trashed copy back over the live key (overwriting a re-created one, throwing when nothing's trashed), and `purge(key?)` permanently deletes one item or empties the whole trash (idempotent). Like `versioning()` it's body-transparent - it never buffers, transforms, or reads the body, so streaming, range downloads, `url()`, and `signedUploadUrl()` all keep working - and has no native dependencies. Trashed objects are hidden from `list()` (unless you list within the prefix); a `delete` of a key inside the trash prefix is a real delete (that's how `purge()` works); deleting a missing key stays a no-op; and bulk `delete([...])` soft-deletes every key. One trashed copy is kept per key (re-deleting replaces it - reach for `versioning()` to keep every generation). Place it first (outermost) so it relocates whatever the rest of the pipeline stored.
- ce69a47: Add a `tiering()` plugin at `files-sdk/tiering` that routes operations between a hot and a cold adapter by size, prefix, or age. The **hot** tier is the instance's own adapter (reached through the rest of the onion); the **cold** tier is a second adapter passed in `cold` (wrapped in its own internal `Files`, so it gets the same retry, capability gating, and `StoredFile` normalization). A required `route({ key, size? })` function decides each operation's tier — `size` is the body's declared length on `upload` (when known), and omitted everywhere else. `upload` lands in the routed tier; `download` / `head` / `url` / `exists` consult it; `delete` removes it; `copy` / `move` locate the source, route the destination by key, and either use a native same-tier op or stream the bytes across when the tiers differ; `list` merges a page from each tier (keys sorted within a page) and paginates the two independently via a composite cursor; `signedUploadUrl` signs against the routed tier. With `fallback: true`, an object's tier is treated as discoverable rather than fixed — reads fall through to the other tier on a miss, `delete` clears both, and an `upload` evicts the other tier so exactly one copy exists; turn it on for `size`-based routing or when you move objects with the new methods. It adds two methods via `extend` (so construct with `createFiles`): `tierOf(key)` reports which tier holds a key, and `tier(key, target)` streams an object across tiers (the lever for age-based transitions — list, check `lastModified`, then tier it down). It's body-transparent (a cross-tier copy streams, never buffers) and has no native dependencies. Place it last (innermost) so body-transforming plugins like `encryption()` wrap both tiers, and address objects by caller-facing keys (the cold adapter doesn't receive the instance `prefix` — give it its own bucket / container).
- 649ac09: The `validation()` plugin now throws a dedicated `ValidationError` (exported from `files-sdk/validation` along with the `ValidationReason` type) with a `reason` discriminant — `"size"`, `"type"`, or `"key"` — so callers can branch on which rule failed without parsing the message. It's backward compatible: `ValidationError extends FilesError`, keeps `code: "Provider"`, and the messages are unchanged, so existing catches keep working. `maxSize`/`minSize` share `reason: "size"` (the message says which bound), and the `signedUploadUrl()` fail-closed throw stays a plain `FilesError` — it's the plugin refusing an unenforceable operation, not the file failing a rule.
- 6aca1e5: Add a `zip()` plugin at `files-sdk/zip` for bundling stored objects into ZIP archives and back out of them. An `extend`-only (Tier C) plugin contributing three methods: `files.zip(selection)` streams many keys as one standard ZIP archive (entries download lazily one at a time, so memory stays flat — pipe it straight into a `Response`), `files.zipTo(key, selection)` stores that archive back as an object, and `files.unzip(key, { into })` extracts an archive's entries into individual objects with content types inferred from their extensions. A selection is an explicit key array or `{ prefix }` (resolved via `listAll`); `method: "store" | "deflate"` picks the compression (deflate via the platform `CompressionStream` — no native deps, works on any adapter) and `name(key)` remaps entry paths. Everything runs through the fully-wrapped instance, so it composes with `encryption()` / `compression()` transparently. Classic ZIP only (no ZIP64: 65,535 entries / 4 GiB caps fail closed), entry names are validated on both sides (duplicates, `..` zip-slip segments, absolute paths), extraction verifies CRC-32/size and refuses encrypted entries and unknown methods, and `unzip` buffers the whole archive (the central directory lives at the end) while `zip` streams.

### Patch Changes

- 53da200: Document why the Azure resumable-upload probe is safe to treat staged (uncommitted) blocks as skippable: blocks Azure garbage-collects before finalization make `commitBlockList` fail loudly (`InvalidBlockList`) rather than committing with gaps, and a retry re-probes correctly. Comment-only; no behavior change.
- bcad8b4: Fix untyped `Blob`/`File` uploads being sent with an empty `Content-Type`. `Blob.type` is `""` (never nullish) when no type was given, so the documented `application/octet-stream` fallback behind a `??` was dead code — the provider received `contentType: ""`. Fixed in the core body normalizer and the same pattern in the box, onedrive, supabase, google-drive, dropbox, r2, uploadthing, and convex adapters.
- 77f6bc6: Fix the array forms of `download`/`head`/`exists`/`delete` ignoring the constructor-level `signal` and `timeout` defaults. The bulk bases call the adapter directly to stay retry-free (as documented), but that also skipped the instance-wide abort signal and timeout — aborting the constructor signal mid-bulk cancelled nothing, and a configured `timeout` never bounded bulk reads or deletes (bulk upload already honored both). Bulk per-item calls now run under the same signal/timeout plumbing as single operations, still without retries.
- 15567cf: Fix the bulk worker pool dying on a sparse/`undefined` array slot. The per-worker guard `return`ed instead of skipping the slot, so with `concurrency: 1` (or as many holes as workers) every key after the hole was silently neither processed nor reported in `results`/`errors`. Only reachable past the type system (a sparse array or an `undefined` element cast in), but the recovery is now to skip just that slot.
- 56965b4: Fix `cache()` serving presigned URLs past their signature when `url()` is called without `expiresIn`. The signature-lifetime cap only applied when the caller passed `expiresIn`, but the adapter signs default calls with a finite lifetime too — so with a long `ttl` (or `ttl: 0`, which disables time-based expiry entirely) the cache kept handing out dead links indefinitely. Entries for default-signed URLs are now capped at the assumed signature lifetime, configurable via the new `defaultUrlExpiresIn` cache option (defaults to the SDK-wide 3600s; set it to match your adapter if you changed its default).
- 0d45bb7: Fix CLI and MCP output of bulk partial-failure errors. The `errors` arrays embed live `FilesError` instances, and a bare `JSON.stringify` drops `message` (a non-enumerable `Error` property) while serializing the enumerable `cause` — the raw provider error, which can carry request ids and response headers the SDK explicitly warns against shipping across a trust boundary. All CLI/MCP serialization now goes through a replacer that emits `{ code, message, aborted, timedOut }` for any embedded `FilesError` and strips `cause`.
- 30f75cc: Fix the CLI truncating piped output on partial failures. Commands called `process.exit()` immediately after writing the structured result to stdout, and POSIX pipe writes are asynchronous — a large payload (e.g. a bulk `head` with errors) could be cut off mid-JSON before the consumer received it. Commands now signal failure via `process.exitCode` and let the process end once stdout drains.
- 74a1226: Fix the CLI eagerly importing optional provider peer dependencies. The bundler previously inlined the registry's lazily-imported provider modules into `dist/cli/index.js`, hoisting their external imports (e.g. `@netlify/blobs`) to the top level — so `files --help` crashed with `ERR_MODULE_NOT_FOUND` unless every optional peer was installed. The build now emits shared chunks so the registry's `await import(...)` calls stay genuinely dynamic: provider-independent commands run without any optional peers installed, and a missing peer only surfaces when its provider is actually selected.
- 4c66027: Fix the CLI blaming `--config-json` for malformed JSON passed to `transfer --to` / `sync --to`. The shared JSON parser hardcoded the flag name in its error message; it now names the flag the user actually passed.
- ee52de2: Fix the CLI silently swallowing non-EPIPE stdout errors. The EPIPE-as-success handler (for `files … | head`-style pipelines), by being registered, also suppressed Node's default throw for every other stdout error — an EIO/EBADF or a full disk behind a redirect let the command exit 0 having written nothing. Non-EPIPE stdout errors now report to stderr and exit 2.
- 9cd61f4: Fix CLI integer flags silently truncating trailing garbage. `--part-size 5MB` parsed to 5 bytes, `--timeout 1s` to 1 millisecond, and `--limit 1.9` to 1 — `parseInt` only rejected fully non-numeric input. Integer flags now require a plain integer and fail loudly otherwise.
- 3584ab9: Fix `contentType()` leaving the caller's source stream locked and open when a stream upload is rejected. With `onMismatch: "reject"` / `onUnknown: "reject"` (or any downstream failure before the replay body was consumed), the peek reader held its lock forever and the underlying request body / file handle was never cancelled. The replay body is now cancelled best-effort when the upload throws.
- e904016: Correct the Dropbox adapter's `expiresIn` documentation. `filesGetTemporaryLink` takes no expiry parameter — every temporary link lives ~4 hours regardless of what's requested — but the docs claimed `expiresIn` was "honored up to the 4h cap". It is validated only (values above 14400s throw); a shorter `expiresIn` is accepted but the link outlives it, so it must not be relied on as a security control with this adapter.
- 024946a: Harden `encryption()` against envelope-metadata tampering and document its threat model precisely. GCM already authenticates the ciphertext and the wrapped DEK, but `fsenc_size` — the declared plaintext size that `head()`/`list()` report — is plain metadata an attacker with raw provider write access could forge; `download()` now verifies it against the decrypted length and throws on a mismatch. The JSDoc now also states explicitly that the envelope is not bound to its object key (an attacker with raw provider write access can splice a whole envelope onto another key): binding to keys would break the documented server-side `copy`/`move` and key-aliasing plugin compositions, so tenants needing that isolation should use separate KEKs.
- fcc252e: Fix `failover()` never failing over on timeouts. The docs promised the default predicate covers "network failures, timeouts, and 5xx", but a per-attempt `timeout` surfaces as an `aborted` error, which the predicate excluded — so a hung primary (the canonical case the plugin exists for) surfaced the timeout instead of trying the secondary. `FilesError` now carries a `timedOut` flag (set only by the configured `timeout`, never by a caller's abort signal), and the default predicate fails over on timeouts while still respecting deliberate caller aborts.
- 781aecc: Harden the fs adapter's resumable-upload `adopt()` against doctored resume tokens. The persisted token's `tempPath` was adopted verbatim, so a tampered token (e.g. one stored in Redis/a DB and rehydrated via `UploadControl.from`) could point the partial-file writes, the completing rename, and the discard delete at an arbitrary filesystem path outside the adapter root. The temp path is fully derived from the traversal-checked key, so it is now recomputed and a token whose `tempPath` doesn't match is rejected — which also catches tokens minted against a different adapter root.
- 9188022: Fix silent file corruption in FTP/SFTP resumable uploads when a chunk is retried. `uploadAt()` appended at the server-side EOF without consulting the chunk's `offset`, so a per-chunk retry after a partial append — or after a lost success reply — appended the chunk again, leaving duplicated bytes in the middle of the file while the upload "succeeded". The drivers now verify the remote size matches the expected offset before appending and, on a mismatch, skip the write and report the server's real offset so the orchestrator re-slices from there.
- c4b1426: Fix the Google Drive adapter creating a duplicate file on every overwrite. Drive has no unique-name constraint and the adapter always called `files.create`, so uploading an existing key a second time left two files carrying the same virtual key — from then on every `head`/`download`/`delete`/`url` on that key from a fresh instance threw `Conflict` (the writer's own id cache masked it). Writes now look the key up first: `upload()` updates the existing file in place, `copy()` deletes the clobbered destination file after a successful copy, and resumable uploads / `signedUploadUrl()` initiate `PATCH` update sessions against the existing file id instead of creating a new one.
- 9ea505f: Stop retrying deterministic failures. The "server ignored the requested byte range" and "only supports the / delimiter" guards throw `Provider`-coded errors from inside the retryable adapter call, and `Provider` was the one code the retry loop treats as transient — so a ranged `download()` with retries against a host that ignores `Range` re-issued (and re-transferred) the full GET on every attempt with backoff in between before surfacing the error. `FilesError` now carries a `permanent` flag that opts a deterministic failure out of retries, set by both guards.
- 3ade008: Fix the offset-HTTP resumable driver (GCS/Firebase/Google Drive) optimistically advancing past a chunk on a `308` response with no `Range` header. In this protocol that response means the server persisted nothing (the probe path already maps it to offset 0), so assuming the whole chunk landed silently skipped its bytes and made the upload fail later at a confusing offset. The chunk now throws a retryable error instead, so the per-chunk retry re-sends it and a token resume re-probes the true offset.
- d99e757: Fix `control.abort()` racing session creation in resumable uploads. Aborting while `driver.begin()` (or a resume `probe()`) was in flight found no discard hook installed yet, so the just-created provider-side session (e.g. an S3 multipart upload, billed until aborted) was never discarded — and the session assignment then re-populated a live token onto the aborted control, violating `abort()`'s terminal contract. The orchestrator now notices the abort right after session setup, discards the provider session, and keeps the control terminal.
- 3ade008: Fix `onProgress` reporting `loaded: Number.MAX_SAFE_INTEGER` when a resumed offset-mode session had already finalized server-side. The probe signals "already done" with a past-the-end sentinel offset, which the orchestrator forwarded verbatim to progress reporting — any UI computing `loaded / total` showed a ~9·10¹⁵-byte upload. The orchestrator now clamps the starting offset to the body size; the upload still completes with the probed result as before.
- 7b7c731: Fix multipart resumable uploads continuing in the background after a part fails. When one part exhausted its retries, `upload()` rejected but the sibling workers kept slicing and uploading every remaining part (burning bandwidth and provider requests), `onProgress` kept firing after rejection, the pause gate flipped the control's status from `"error"` back to `"uploading"`, and a later `resume()` could wake paused workers into the dead run. A part failure now latches the run: new dispatches stop, in-flight sibling attempts are aborted via a run-scoped signal, parked workers wake up and bail, and the control's status stays `"error"`.
- ea7051e: Fix `softDelete()` dropping the caller's operation options on the trash move. A `signal`/`timeout`/`retries` passed to `files.delete(key, opts)` was silently ignored for the re-routed move, making the delete un-abortable and unbounded. The options now thread through.
- d0061bb: Fix the Supabase adapter passing `responseContentDisposition` straight through as Supabase's `download` filename. Supabase's `download: string` option means "attachment **named** this", so `responseContentDisposition: "attachment"` served a file literally named `attachment`, and a full `attachment; filename="report.pdf"` value produced a garbled filename embedding the whole header. Bare `attachment` now maps to `download: true`, a `filename=` parameter maps to that name, and dispositions Supabase can't express (e.g. `inline`) throw instead of being mislabeled.
- 2e4d2e2: Fix the Supabase adapter's flat `list()` missing nested objects. The no-delimiter path used the legacy V1 `list()` API, which is folder-scoped and non-recursive — a bucket with nested keys (`docs/a.txt`) listed phantom zero-byte rows for the folders and never returned the nested objects, so `listAll`, `search()`, `sync`/`transfer`, and every list-based plugin silently missed them; a partial prefix (`prefix: "do"`) returned nothing at all. The flat path now uses the V2 list API: a recursive string-prefix scan over full keys with a real server cursor. Note that flat-list cursors are now opaque V2 cursors rather than numeric offsets — don't persist cursors across versions.
- 2b8780f: Fix `tiering()`'s `tierOf()`/`tier()` ignoring the instance `prefix`. The extend methods built their hot-tier runner from the bare adapter, while every other operation goes through the plugin chain and gets the prefix applied — so with `prefix` set, `files.exists(key)` was `true` but `files.tierOf(key)` returned `undefined`, and `files.tier(key, …)` threw `NotFound` (or touched a same-named unprefixed object). The extend runner now re-applies the prefix. `Files` also gains a public `prefix` getter so plugins can do the same.
- 9235eab: Fix `tiering()`'s merged `list()` emitting a both-tier key twice across pages. The "hot wins" dedup was per page while the two tiers paginate independently, so a key present in both tiers (exactly the stale-shadow state `fallback` mode anticipates after a crash mid-eviction) appeared twice — with potentially different sizes/etags — once each tier's stream reached it, breaking `listAll`/`sync`/`search` consumers. Merged listing is now globally key-ordered: each page emits entries only up to the lowest page boundary among tiers that still have more, holding the rest back via a `skip` marker in the composite cursor, which makes cross-page duplicates (of keys and of delimiter prefixes) impossible. An undecodable composite cursor now throws instead of silently restarting the listing from the top. Composite cursors changed shape — don't carry a list cursor across versions.
- dac57c2: Fix `transfer()` and `sync()` leaking the source download stream when the destination upload fails. A destination that rejects before draining the body (auth error, rejected metadata, a fail-closed plugin) left the already-opened source stream — an HTTP response or file descriptor — neither drained nor cancelled, leaking one per failed key on a large walk. The stream is now cancelled best-effort before the per-key error is recorded.
- bec8e9f: Correct the UploadThing adapter's `copy()` documentation: it claimed the re-upload streams without buffering, but `uploadFiles` requires a Blob, so the body is fully buffered in memory — exactly the multi-GB scenario the comment claimed to protect against. The comment now states the real behavior and its memory implications. No behavior change.
- f390c80: Fix `usage()` miscounting `bytesDown` for buffer-backed bodies read via `stream()`. The wrapper eagerly marked `stream()` as counted, which only holds for read-once stream sources — buffer-backed files (the memory adapter, or anything a transforming plugin buffered) have a repeatable `stream()`, so reading one twice double-counted, and opening a stream without reading it zeroed out the count of a later `text()`/`arrayBuffer()` that actually moved the bytes. The count is now claimed by the first read channel that actually moves bytes, at most once per body.
- c095770: Fix a nested-key collision in the `versioning()` plugin's version store. `a`'s version directory (`.versions/a/`) is a prefix of `a/b`'s (`.versions/a/b/`), so `versions("a")` reported `a/b`'s snapshots as versions of `a`, `restore("a")` could silently overwrite `a` with `a/b`'s old bytes, and pruning `a` could delete `a`'s only snapshot while counting `a/b`'s against the limit. Version ids never contain `/`, so listings now ignore anything deeper than the key's own directory, and `restore()` rejects a `versionId` containing `/`. The on-disk layout is unchanged — existing version stores keep working.
- 9055fba: Fix `versioning()`'s prune reading only the first list page. Once a key's history exceeded one provider page, `items.length <= max` could be satisfied by a partial page and pruning was skipped or under-counted, so the configured `limit` wasn't enforced promptly. Prune now paginates the version directory to exhaustion, like `versions()` does.
- ce0c3f5: Fix an off-by-one in the `zip()` plugin's classic-format limits. The writer accepted exactly 65,535 entries and sizes/offsets of exactly `0xFFFFFFFF` — but those are the ZIP64 sentinel values, which the plugin's own `unzip()` (and any ZIP64-aware reader) treats as "the real value lives in a ZIP64 record", so such an archive couldn't be read back. The limit checks are now `>=`, refusing the sentinel values themselves.
