API reference
Overview
class Storage<Raw = unknown> extends ReadOnlyStorage {
constructor(opts: { adapter: Adapter<Raw> });
readonly raw: Raw;
readonly snapshots: { create, list, head, delete, get };
readonly forks: { create, list, head, delete, get };
// Reads — inherited from ReadOnlyStorage
download(path, opts?): Promise<StorageItem | …>;
head(path, opts?): Promise<StorageItemMeta>;
list(opts?): Promise<ListResult>;
url(path, opts?): Promise<string>;
// Writes — added by Storage
upload(path, body, opts?): Promise<StorageItemMeta>;
delete(path, opts?): Promise<void>;
copy(from, to, opts?): Promise<void>;
move(from, to, opts?): Promise<void>;
uploadUrl(path, opts?): Promise<UploadUrlResult>;
}
storage.snapshots.get(id) returns a ReadOnlyStorage — same download overloads, no writes. storage.forks.get(name) returns a full Storage<Raw> so writes propagate and the typed raw survives fork navigation.
Every method that touches I/O accepts an optional signal?: AbortSignal. Cancelled operations throw StorageError({ code: 'Aborted' }) — see Get Started for the typed error catalogue.
upload()
storage.upload(
path: string,
body: BodyInput,
opts?: UploadOptions,
): Promise<StorageItemMeta>;
type BodyInput =
| string
| Uint8Array
| ArrayBuffer
| Blob
| ReadableStream<Uint8Array>;
await storage.upload('report.pdf', body, {
contentType: 'application/pdf',
metadata: { uploadedBy: 'alice' },
});
upload auto-decides multipart vs single PUT: ReadableStream is always multipart (size unknown upfront); size-known bodies (string, Uint8Array, Blob, ArrayBuffer) go multipart only above the threshold.
| Option | Type | Notes |
|---|---|---|
contentType | string | Defaults to application/octet-stream. |
cacheControl | string | Cache-Control header on the stored object. |
metadata | Record<string, string> | User-defined headers. Vercel drops them; S3 lowercases keys. |
multipart | boolean | Force on/off. Default: auto-decide. |
multipartThreshold | number | Size in bytes above which auto-decide picks multipart. Default 5 * 1024 * 1024. |
partSize, concurrency | number | Backend-specific multipart tuning. |
onProgress | (e: { loaded, total }) => void | Multipart only. |
signal | AbortSignal | Cancels in-flight upload. |
download()
Six overloads — the default returns the full StorageItem; the as: option returns just the body in the requested shape.
storage.download(path, opts?): Promise<StorageItem>;
storage.download(path, { as: 'stream', ... }): Promise<ReadableStream<Uint8Array>>;
storage.download(path, { as: 'text', ... }): Promise<string>;
storage.download(path, { as: 'bytes', ... }): Promise<Uint8Array>;
storage.download(path, { as: 'blob', ... }): Promise<Blob>;
storage.download(path, { as: 'json', ... }): Promise<unknown>;
const item = await storage.download('photo.jpg');
// → { path, size, contentType, etag, lastModified, metadata, body: Uint8Array }
const text = await storage.download('readme.md', { as: 'text' });
const bytes = await storage.download('blob.bin', { as: 'bytes' });
const stream = await storage.download('video.mp4', { as: 'stream' });
const config = await storage.download('cfg.json', { as: 'json' });
Byte-range reads via range: { offset, length } — matches HTTP Range semantics (extends-past-EOF returns the bytes that exist):
const slice = await storage.download('big.bin', {
as: 'bytes',
range: { offset: 4096, length: 1024 },
});
slice.byteLength; // 1024
| Option | Type | Notes |
|---|---|---|
as | 'stream' | 'text' | 'bytes' | 'blob' | 'json' | Body shape. Omit for the full StorageItem. |
range | { offset: number; length: number } | Offset/length form to dodge end-bound ambiguity. |
signal | AbortSignal |
head()
storage.head(path: string, opts?: { signal? }): Promise<StorageItemMeta>;
const meta = await storage.head('photo.jpg');
meta.size; // number
meta.contentType; // string
meta.etag; // string
meta.lastModified; // Date
meta.metadata; // Record<string, string> | undefined
Returns the same shape as one entry from list(). No body — call download() if you need the bytes.
list()
storage.list(opts?: ListOptions): Promise<ListResult>;
type ListResult = {
items: StorageItemMeta[];
cursor?: string;
};
const page = await storage.list({ prefix: 'reports/', limit: 100 });
for (const item of page.items) {
console.log(item.path, item.size);
}
if (page.cursor) {
const next = await storage.list({ prefix: 'reports/', cursor: page.cursor });
}
| Option | Type | Notes |
|---|---|---|
prefix | string | Filter to keys under a path prefix. |
limit | number | Max items returned per page. Backend caps apply. |
cursor | string | Continuation token from the previous response. |
delimiter | string | Hierarchical listings (e.g. '/' for folder-like grouping). |
signal | AbortSignal |
copy()
storage.copy(
from: string,
to: string,
opts?: { signal?: AbortSignal },
): Promise<void>;
await storage.copy('inbox/photo.jpg', 'archive/2026/photo.jpg');
Server-side copy where the provider supports it; falls back to download-and-reupload otherwise. The destination overwrites silently if it exists.
move()
storage.move(
from: string,
to: string,
opts?: { signal?: AbortSignal },
): Promise<void>;
await storage.move('tmp/upload-42.bin', 'final/upload-42.bin');
Implemented as copy then delete. Not atomic — if the delete fails after the copy succeeds, both keys exist.
delete()
storage.delete(path: string, opts?: { signal?: AbortSignal }): Promise<void>;
await storage.delete('old/report.pdf');
No-op if the key doesn’t exist on most providers (S3 semantics).
url()
storage.url(path: string, opts?: UrlOptions): Promise<string>;
// 5-minute GET URL.
const url = await storage.url('photo.jpg', { expiresIn: 300 });
// Force a download with a filename.
const dl = await storage.url('report.pdf', {
expiresIn: 300,
responseContentDisposition: 'attachment; filename="report.pdf"',
});
| Option | Type | Notes |
|---|---|---|
expiresIn | number | Seconds. Default is provider-specific. |
responseContentDisposition | string | Set on the response (e.g. force-download). |
responseContentType | string | Override the response Content-Type. |
signal | AbortSignal |
The fs adapter returns file:// URLs — explicitly not signed, local-dev only.
uploadUrl()
storage.uploadUrl(path: string, opts?: UploadUrlOptions): Promise<UploadUrlResult>;
type UploadUrlResult =
| { method: 'PUT'; url: string; headers?: Record<string, string> }
| { method: 'POST'; url: string; fields: Record<string, string> };
uploadUrl returns a discriminated value — PUT is the default; the adapter switches to POST (when supported) if you set maxSize / minSize so the provider can server-enforce the upload bounds.
// PUT — the client does fetch(url, { method: 'PUT', body }).
const put = await storage.uploadUrl('new.jpg', {
expiresIn: 300,
contentType: 'image/jpeg',
});
// → { method: 'PUT', url, headers? }
// POST — client submits multipart/form-data with `fields` + the file.
// The provider rejects uploads above `maxSize` or with the wrong type.
const post = await storage.uploadUrl('new.jpg', {
expiresIn: 300,
maxSize: 5_000_000,
contentType: 'image/jpeg',
});
// → { method: 'POST', url, fields }
| Option | Type | Notes |
|---|---|---|
expiresIn | number | Seconds. |
contentType | string | Locks the request to a Content-Type. |
maxSize | number | Cap upload size in bytes. Switches to POST on adapters that support it. |
minSize | number | Minimum upload size. |
signal | AbortSignal |
See Adapters for which providers enforce maxSize / minSize server-side.
Snapshots
A snapshot is a frozen, read-only view of a bucket at a moment in time. Native on Tigris (zero-copy via createBucketSnapshot); emulated as sibling buckets/containers/folders on every other provider via server-side copy.
type SnapshotInfo = {
readonly id: string;
readonly name?: string;
readonly createdAt: Date;
};
snapshots.create()
storage.snapshots.create(opts?: CreateSnapshotOptions): Promise<SnapshotInfo>;
const snap = await storage.snapshots.create({ name: 'pre-migration' });
snap.id; // backend-generated, opaque
snap.name; // 'pre-migration'
snap.createdAt; // Date
Captures the bucket’s current state. The id is always backend-generated (Tigris assigns one; copy-based adapters compose it as the parent name plus a -snapshot- separator and a 25-digit timestamp + random suffix). name is a human-readable label you provide.
| Option | Type | Notes |
|---|---|---|
name | string | Human-readable label. Stored on SnapshotInfo; useful for retrieval-by-name in your own code. |
onProgress | (e: { scanned, total? }) => void | Copy-based adapters fire this as they iterate keys. Tigris finishes in one call so it isn’t called. |
signal | AbortSignal | Cancels mid-copy on copy-based adapters; cancels the API call on Tigris. |
snapshots.list()
storage.snapshots.list(): Promise<SnapshotInfo[]>;
const all = await storage.snapshots.list();
for (const snap of all) {
console.log(snap.id, snap.name, snap.createdAt);
}
Returns every snapshot of the current bucket. No pagination — snapshot counts are expected to be small. No options.
snapshots.head()
storage.snapshots.head(id: string, opts?: { signal? }): Promise<SnapshotInfo>;
const meta = await storage.snapshots.head(snap.id);
meta.createdAt; // Date — useful for "snapshot is older than X" checks
Fetches a single snapshot’s metadata by id. Throws StorageError({ code: 'NotFound' }) if no snapshot with that id exists. Cheaper than list() when you already know the id.
snapshots.delete()
storage.snapshots.delete(id: string, opts?: { signal? }): Promise<void>;
await storage.snapshots.delete(snap.id);
Deletes a snapshot. On Tigris this is one call; on copy-based adapters it removes the sibling bucket/folder. Forks that were created from this snapshot keep their data — fromSnapshot on ForkInfo will still point at the deleted id, but reads against the fork itself are unaffected.
snapshots.get()
storage.snapshots.get(id: string): ReadOnlyStorage;
const reader = storage.snapshots.get(snap.id);
// Same overloaded download from ReadOnlyStorage.
const before = await reader.download('photo.jpg', { as: 'text' });
const meta = await reader.head('photo.jpg');
const page = await reader.list({ prefix: 'reports/' });
const url = await reader.url('photo.jpg', { expiresIn: 300 });
Returns a ReadOnlyStorage bound to the snapshot — the same overloaded download, plus head, list, and url. No writes (the snapshot is frozen). Synchronous: this is just a handle; the lookup happens on the first read.
Forks
A fork is a writable branch of a bucket — your own isolated namespace, seeded from a snapshot or from the parent’s live state. Native on Tigris (zero-copy via createBucket({ sourceBucketName, sourceBucketSnapshot })); emulated as sibling buckets/containers/folders on every other provider.
type ForkInfo = {
readonly name: string;
readonly fromSnapshot?: string;
readonly createdAt: Date;
};
forks.create()
storage.forks.create(opts: ForkOptions): Promise<ForkInfo>;
// Fork from a specific snapshot — reproducible.
await storage.forks.create({
name: 'experiment-42',
fromSnapshot: snap.id,
});
// Fork from the parent's current live state — seed-at-create.
await storage.forks.create({ name: 'experiment-43' });
Creates a writable fork. With fromSnapshot, the fork starts at exactly that frozen state. Without it, copy-based adapters copy the parent’s live state at the moment of creation; native adapters pass the omission through to their fork API.
| Option | Type | Notes |
|---|---|---|
name | string | Required. Must be unique within the parent. Used as the fork’s identifier in every other forks.* call. |
fromSnapshot | string | Snapshot id to seed from. Omit to seed from the parent’s live state. |
onProgress | (e: { copied, total, bytes? }) => void | Copy-based adapters fire this per object copied. |
signal | AbortSignal | Cancels mid-copy on copy-based adapters; cancels the API call on Tigris. |
forks.list()
storage.forks.list(): Promise<ForkInfo[]>;
const all = await storage.forks.list();
for (const fork of all) {
console.log(fork.name, fork.fromSnapshot, fork.createdAt);
}
Returns every fork of the current bucket. fromSnapshot is undefined for forks created from live state. No pagination, no options.
forks.head()
storage.forks.head(name: string, opts?: { signal? }): Promise<ForkInfo>;
const meta = await storage.forks.head('experiment-42');
meta.fromSnapshot; // id or undefined
Fetches a single fork’s metadata by name. Throws StorageError({ code: 'NotFound' }) if no fork with that name exists.
forks.delete()
storage.forks.delete(name: string, opts?: { signal? }): Promise<void>;
await storage.forks.delete('experiment-42');
Deletes the fork and its data. The parent is unaffected, and the snapshot the fork was seeded from (if any) is also unaffected.
forks.get()
storage.forks.get(name: string): Storage<Raw>;
const fork = storage.forks.get('experiment-42');
// Full read + write surface.
await fork.upload('config.json', JSON.stringify({ flag: true }));
const cfg = await fork.download('config.json', { as: 'json' });
// Nested forks: same typed `raw` propagates through.
const nested = fork.forks.get('sub-experiment');
nested.raw; // same Raw type as storage.raw
Returns a full Storage<Raw> bound to the fork — every read and write method, plus snapshots and forks of its own. The Raw generic survives navigation, so storage.raw and storage.forks.get('x').raw are the same client type without a cast.