Immutable
release. Only release title and notes can be modified.
Minor Changes
- ff814cc: Add an
audit()plugin atfiles-sdk/auditthat writes a structured who/what/when record of every mutation to an awaited sink — the durable, awaitable counterpart to the fire-and-forgetonActionhook. Each audited operation produces oneAuditRecordcarrying the verb, the caller-facing key (orfrom/to), an optionalactor, the start time and duration, the outcome, and — on a successfulupload— 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); passevents: "all"to also audit reads, or an explicit list to record exactly the verbs you name. Resolveactorsynchronously from your request context to attribute each record. It's body-transparent (never buffers, transforms, or reads the body —sizecomes 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; bulkupload([...])/delete([...])fan out to one record per item, each flaggedbulk: true. It'swrap-only, so plainnew Files({ plugins })works. Place it first (outermost) so it records the caller's logical intent — adeletean innersoftDelete()turns into amoveis still audited as thedeletethe caller asked for. - daca585: Add a
cache()plugin atfiles-sdk/cache— an LRU/KV cache in front of the cheap read verbs. A repeathead()orurl()(and, opt-in, a smalldownload()) 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.headcaches metadata only (a hit's body still lazy-fetches on access, matching the uncachedheadcontract);urlcaches per url-options signature and caps each entry at its ownexpiresInso a presigned URL is never handed out past its signature;downloadis off by default and, when enabled viaoperations: ["download"], buffers only known-length bodies at or undermaxBytes(default 1 MiB) so streaming and large objects keep working. Defaults to a bounded in-memory LRU (maxEntries, default 1000), or pass your ownCacheStoreto back it with a shared KV. Entries honor attl(default 60s;0disables 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 usesextendforinvalidateCache(key?),cacheStats(), andresetCacheStats()— construct withcreateFilesto 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 callinvalidateCache()and treat the cache as eventually-consistent. - 83d6eb4: Add a
failover()plugin atfiles-sdk/failoverthat 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 insecondaries(a singleAdapteror an array for a multi-region chain), each wrapped in its own internalFilesso it gets the same retry, capability gating, andStoredFilenormalization. Every verb runs the same way: try the primary; if it throws andshouldFailoversays 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 onProvidererrors (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 ownshouldFailoverto widen it (e.g. read through to a replica onNotFound) or narrow it. This is the availability counterpart totiering()(which partitions by key/size): failover treats each secondary as a full replica, so it never splits or merges across backends —listreturns 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'sreplication()). A streamingupload(aReadableStreambody) can't be replayed, so it runs against the primary alone and isn't failed over. An optionalonFailovercallback (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 (wraponly), so it works with plainnew Files({ plugins }). Place it last (innermost) so body-transforming plugins likeencryption()wrap every backend, and give each secondary its own bucket / container (secondaries receive caller-facing keys, without the instanceprefix). Failover buys availability, not convergence — reconcile a secondary written during an outage withsync/transfer, or keep it current withreplication(). - 581c97f: Add a queryable
files.capabilitiessurface 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 anAdapterCapabilitiessnapshot with eight fields, each mirroring an operation the unified API actually exposes:rangeRead,uploadProgress,delimiter,metadata,cacheControl, andmultipartare derived live from the same per-adapter flags and optional methods the wrapper already gates on (so they can never drift from runtime behavior), whileserverSideCopyandsignedUrl({ 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.supportedistruewhenurl()can mint a signed or tokenized URL (not just a permanent public link);maxExpiresInis set only where a provider enforces a hardexpiresInceiling 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 optionalsupportsServerSideCopyandsignedUrlfields alongside the existingsupports*flags; both are advisory and gate nothing. See the new Capabilities and Provider gaps documentation. - 81e0e64: Add a
neonadapter atfiles-sdk/neonfor 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 standardAWS_*variables thatneon dev/neon env pullinject for the linked branch —endpointfromAWS_ENDPOINT_URL_S3, region fromAWS_REGION(thenNEON_STORAGE_REGION, thenus-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 infiles-sdk/providersand exposed through the CLI. - 2275982: Add an opt-in
receiptsoption that surfaces a provenanceReceiptfor 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 withreceipts: trueto attach aReceipt({ op, provider, key, bytes?, etag?, sha256?, durationMs, ts }) to the successonActionevent of each mutating call — an additivereceiptfield on the existing hook, with no new operation, callback, or changed return type. Every field exceptsha256is derived from the work the SDK already does for the hook (timing, the adapter name, the caller-facing key, andbytes/etagread straight off theUploadResult), so plainreceipts: trueadds no per-call cost.sha256is the one field with a real per-call cost and is opt-in by name: passreceipts: { sha256: true }to fingerprint the upload body as passed toupload()— taken before any plugin transform, so it matches whatdownloadgives back rather than the (possibly encrypted/compressed) bytes on disk — a lowercase-hex SHA-256, present only on anuploadof a buffered body. A streaming upload is never buffered to hash it (so it carries no fingerprint), anddelete/copy/movetransfer no content of their own; withsha256off, the body is never read. Reads,signedUploadUrl, failures, bulk array calls, and receipts-off instances all leaveevent.receiptunset. See the new Receipts documentation. - 5a77a58: Add a
signedUrlPolicy()plugin atfiles-sdk/signed-url-policythat enforces safe defaults on the two URL-minting operations, turning the security caveatsurl()andsignedUploadUrl()document into the default. Onurl()it forces a downloadContent-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 existingattachment(and itsfilename), and clampsexpiresIntomaxExpiresIn. OnsignedUploadUrl()it clampsexpiresInto the same cap and, whenmaxUploadSizeis set, guarantees a server-enforcedmaxSizeis 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()forcesattachment). Setdisposition: falseto 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 atfiles-sdk/soft-deletethat turnsdeleteinto a recoverable move into a trash prefix - a recycle bin for any adapter. Instead of destroying an object, adeleteserver-side moves it to"<prefix>/<key>"(.trash/by default); the bytes only leave storage when youpurge(). It adds three methods viaextend(so construct withcreateFiles):trashed()lists what's in the trash (each entry carries the originalkeyplus a downloadabletrashKey),restore(key)moves the trashed copy back over the live key (overwriting a re-created one, throwing when nothing's trashed), andpurge(key?)permanently deletes one item or empties the whole trash (idempotent). Likeversioning()it's body-transparent - it never buffers, transforms, or reads the body, so streaming, range downloads,url(), andsignedUploadUrl()all keep working - and has no native dependencies. Trashed objects are hidden fromlist()(unless you list within the prefix); adeleteof a key inside the trash prefix is a real delete (that's howpurge()works); deleting a missing key stays a no-op; and bulkdelete([...])soft-deletes every key. One trashed copy is kept per key (re-deleting replaces it - reach forversioning()to keep every generation). Place it first (outermost) so it relocates whatever the rest of the pipeline stored. - ce69a47: Add a
tiering()plugin atfiles-sdk/tieringthat 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 incold(wrapped in its own internalFiles, so it gets the same retry, capability gating, andStoredFilenormalization). A requiredroute({ key, size? })function decides each operation's tier —sizeis the body's declared length onupload(when known), and omitted everywhere else.uploadlands in the routed tier;download/head/url/existsconsult it;deleteremoves it;copy/movelocate the source, route the destination by key, and either use a native same-tier op or stream the bytes across when the tiers differ;listmerges a page from each tier (keys sorted within a page) and paginates the two independently via a composite cursor;signedUploadUrlsigns against the routed tier. Withfallback: true, an object's tier is treated as discoverable rather than fixed — reads fall through to the other tier on a miss,deleteclears both, and anuploadevicts the other tier so exactly one copy exists; turn it on forsize-based routing or when you move objects with the new methods. It adds two methods viaextend(so construct withcreateFiles):tierOf(key)reports which tier holds a key, andtier(key, target)streams an object across tiers (the lever for age-based transitions — list, checklastModified, 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 likeencryption()wrap both tiers, and address objects by caller-facing keys (the cold adapter doesn't receive the instanceprefix— give it its own bucket / container). - 649ac09: The
validation()plugin now throws a dedicatedValidationError(exported fromfiles-sdk/validationalong with theValidationReasontype) with areasondiscriminant —"size","type", or"key"— so callers can branch on which rule failed without parsing the message. It's backward compatible:ValidationError extends FilesError, keepscode: "Provider", and the messages are unchanged, so existing catches keep working.maxSize/minSizesharereason: "size"(the message says which bound), and thesignedUploadUrl()fail-closed throw stays a plainFilesError— it's the plugin refusing an unenforceable operation, not the file failing a rule. - 6aca1e5: Add a
zip()plugin atfiles-sdk/zipfor bundling stored objects into ZIP archives and back out of them. Anextend-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 aResponse),files.zipTo(key, selection)stores that archive back as an object, andfiles.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 vialistAll);method: "store" | "deflate"picks the compression (deflate via the platformCompressionStream— no native deps, works on any adapter) andname(key)remaps entry paths. Everything runs through the fully-wrapped instance, so it composes withencryption()/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, andunzipbuffers the whole archive (the central directory lives at the end) whilezipstreams.
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
commitBlockListfail loudly (InvalidBlockList) rather than committing with gaps, and a retry re-probes correctly. Comment-only; no behavior change. - bcad8b4: Fix untyped
Blob/Fileuploads being sent with an emptyContent-Type.Blob.typeis""(never nullish) when no type was given, so the documentedapplication/octet-streamfallback behind a??was dead code — the provider receivedcontentType: "". 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/deleteignoring the constructor-levelsignalandtimeoutdefaults. 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 configuredtimeoutnever 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/
undefinedarray slot. The per-worker guardreturned instead of skipping the slot, so withconcurrency: 1(or as many holes as workers) every key after the hole was silently neither processed nor reported inresults/errors. Only reachable past the type system (a sparse array or anundefinedelement cast in), but the recovery is now to skip just that slot. - 56965b4: Fix
cache()serving presigned URLs past their signature whenurl()is called withoutexpiresIn. The signature-lifetime cap only applied when the caller passedexpiresIn, but the adapter signs default calls with a finite lifetime too — so with a longttl(orttl: 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 newdefaultUrlExpiresIncache 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
errorsarrays embed liveFilesErrorinstances, and a bareJSON.stringifydropsmessage(a non-enumerableErrorproperty) while serializing the enumerablecause— 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 embeddedFilesErrorand stripscause. - 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 bulkheadwith errors) could be cut off mid-JSON before the consumer received it. Commands now signal failure viaprocess.exitCodeand 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 — sofiles --helpcrashed withERR_MODULE_NOT_FOUNDunless every optional peer was installed. The build now emits shared chunks so the registry'sawait 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-jsonfor malformed JSON passed totransfer --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 5MBparsed to 5 bytes,--timeout 1sto 1 millisecond, and--limit 1.9to 1 —parseIntonly 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. WithonMismatch: "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
expiresIndocumentation.filesGetTemporaryLinktakes no expiry parameter — every temporary link lives ~4 hours regardless of what's requested — but the docs claimedexpiresInwas "honored up to the 4h cap". It is validated only (values above 14400s throw); a shorterexpiresInis 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, butfsenc_size— the declared plaintext size thathead()/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-sidecopy/moveand 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-attempttimeoutsurfaces as anabortederror, which the predicate excluded — so a hung primary (the canonical case the plugin exists for) surfaced the timeout instead of trying the secondary.FilesErrornow carries atimedOutflag (set only by the configuredtimeout, 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'stempPathwas adopted verbatim, so a tampered token (e.g. one stored in Redis/a DB and rehydrated viaUploadControl.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 whosetempPathdoesn'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'soffset, 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 everyhead/download/delete/urlon that key from a fresh instance threwConflict(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()initiatePATCHupdate 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, andProviderwas the one code the retry loop treats as transient — so a rangeddownload()with retries against a host that ignoresRangere-issued (and re-transferred) the full GET on every attempt with backoff in between before surfacing the error.FilesErrornow carries apermanentflag 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
308response with noRangeheader. 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 whiledriver.begin()(or a resumeprobe()) 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, violatingabort()'s terminal contract. The orchestrator now notices the abort right after session setup, discards the provider session, and keeps the control terminal. - 3ade008: Fix
onProgressreportingloaded: Number.MAX_SAFE_INTEGERwhen 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 computingloaded / totalshowed 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),onProgresskept firing after rejection, the pause gate flipped the control's status from"error"back to"uploading", and a laterresume()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. Asignal/timeout/retriespassed tofiles.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
responseContentDispositionstraight through as Supabase'sdownloadfilename. Supabase'sdownload: stringoption means "attachment named this", soresponseContentDisposition: "attachment"served a file literally namedattachment, and a fullattachment; filename="report.pdf"value produced a garbled filename embedding the whole header. Bareattachmentnow maps todownload: true, afilename=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 V1list()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, solistAll,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()'stierOf()/tier()ignoring the instanceprefix. 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 withprefixset,files.exists(key)wastruebutfiles.tierOf(key)returnedundefined, andfiles.tier(key, …)threwNotFound(or touched a same-named unprefixed object). The extend runner now re-applies the prefix.Filesalso gains a publicprefixgetter so plugins can do the same. - 9235eab: Fix
tiering()'s mergedlist()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 statefallbackmode anticipates after a crash mid-eviction) appeared twice — with potentially different sizes/etags — once each tier's stream reached it, breakinglistAll/sync/searchconsumers. 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 askipmarker 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()andsync()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, butuploadFilesrequires 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()miscountingbytesDownfor buffer-backed bodies read viastream(). The wrapper eagerly markedstream()as counted, which only holds for read-once stream sources — buffer-backed files (the memory adapter, or anything a transforming plugin buffered) have a repeatablestream(), so reading one twice double-counted, and opening a stream without reading it zeroed out the count of a latertext()/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 ofa/b's (.versions/a/b/), soversions("a")reporteda/b's snapshots as versions ofa,restore("a")could silently overwriteawitha/b's old bytes, and pruningacould deletea's only snapshot while countinga/b's against the limit. Version ids never contain/, so listings now ignore anything deeper than the key's own directory, andrestore()rejects aversionIdcontaining/. 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 <= maxcould be satisfied by a partial page and pruning was skipped or under-counted, so the configuredlimitwasn't enforced promptly. Prune now paginates the version directory to exhaustion, likeversions()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 exactly0xFFFFFFFF— but those are the ZIP64 sentinel values, which the plugin's ownunzip()(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.