transfer

Stream every object under a prefix from one Files instance to another - the cross-provider migration that the unified surface uniquely enables, built entirely on listAll, download, and upload.

copy and move live inside a single adapter. A migration spans two — and that's the one thing a unified surface uniquely enables. transfer(source, dest, options?) walks every object the source exposes and streams each one straight to the dest, whatever the backends are.

import { Files, transfer } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { r2 } from "files-sdk/r2";

const from = new Files({ adapter: s3({ bucket: "old" }) });
const to = new Files({
  adapter: r2({ bucket: "new", accountId, accessKeyId, secretAccessKey }),
});

const { transferred, errors } = await transfer(from, to, {
  prefix: "uploads/",
  onProgress: ({ done, total, key }) =>
    console.log(`${done}/${total}${key}`),
});

Both arguments are full Files instances, not raw adapters, so each leg honors its own instance's prefix, retries, timeouts, and hooks. Each object is streamed download-to-upload, so the destination never sees a buffered copy of a large file.

transfer is a one-shot copy. For an incremental, optionally-pruning mirror — skip-unchanged, delete extraneous keys, dry-run the plan — reach for sync instead.

What travels

The body, content type, and user metadata move with each object. Destination-assigned fields (etag, lastModified) are fresh on the other side, and Cache-Control is not carried — a StoredFile doesn't expose it. Metadata is dropped for adapters with no metadata primitive; a metadata key a destination adapter rejects outright (Bunny, Appwrite, PocketBase) surfaces as a per-key error rather than failing the whole run.

Result shape

Like the bulk actions, transfer does not throw on a partial failure. Successes, skips, and failures come back separated, in walk order:

const { transferred, skipped, errors } = await transfer(from, to);
FieldContents
transferredSource keys copied to the destination.
skippedKeys skipped because they already existed. Omitted when none.
errorsPer-key { key, error } failures. Omitted when every key wins.

error is always a normalized FilesError.

Options

await transfer(from, to, {
  prefix: "uploads/", // only walk keys under this prefix
  transformKey: (key) => `archive/${key}`, // remap each key for the destination
  overwrite: false, // skip keys already at the destination
  concurrency: 16, // keys in flight at once (default 8)
  limit: 500, // page size for the underlying walk
  stopOnError: true, // bail at the first failure
  signal: controller.signal, // abort the whole transfer
  onProgress: ({ done, key, status }) => {},
});

transformKey maps the logical key — each instance applies its own prefix independently — which makes re-homing under a new namespace (or moving between two prefixed instances) a one-liner. With overwrite: false, every key costs one extra exists() against the destination.

concurrency bounds how many objects stream at once (and therefore memory, since each in-flight key holds one open stream). It's ignored under stopOnError, which runs sequentially and returns the keys transferred so far plus the first error. signal is forwarded to every list / exists / download / upload and stops new keys from being scheduled; keys already in flight may still finish or surface as errors.

Progress

onProgress fires once per key as it settles, carrying a running done count, the total, the key, and whether it was transferred or skipped. The source is walked in full before any transfer begins, so total is the final denominator from the very first event.

await transfer(from, to, {
  onProgress: ({ done, total, key, status }) => {
    console.log(`${done}/${total}: ${key} (${status})`);
  },
});

On this page