Skip to content

files-sdk@1.9.0

Latest

Choose a tag to compare

@github-actions github-actions released this 14 Jun 20:00
Immutable release. Only release title and notes can be modified.
212aed4

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 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 returned 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.