---
title: versioning
description: Snapshot an object's prior bytes before every overwrite or delete and roll a key back with versions() and restoreVersion() - server-side, body-transparent.
related:
  - /guides/restore-deleted-files-s3-r2
---

The built-in `versioning()` plugin keeps a history of every object. Before an [`upload`](/docs/api/upload), [`delete`](/docs/api/delete), or the destination of a [`copy`](/docs/api/copy) / [`move`](/docs/api/move) clobbers an existing object, it server-side-copies the current bytes to a time-stamped key under a hidden version prefix. Two new methods - `versions()` and `restoreVersion()` - let you list that history and roll a key back.

Unlike [`encryption()`](/docs/plugins/encryption) and [`compression()`](/docs/plugins/compression), it's **body-transparent**: it never buffers, transforms, or even reads the body, so streaming, [range downloads](/docs/api/download), [`url()`](/docs/api/url), and [`signedUploadUrl()`](/docs/api/signed-upload-url) all keep working. It has **no native dependencies** and works on any adapter.

```ts lineNumbers
import { createFiles } from "files-sdk";
import { r2 } from "files-sdk/r2";
import { versioning } from "files-sdk/versioning";

const files = createFiles({
  adapter: r2({ bucket: "uploads" }),
  plugins: [versioning({ limit: 10 })],
});

await files.upload("notes.txt", "v1");
await files.upload("notes.txt", "v2"); // "v1" snapshotted first

const [previous] = await files.versions("notes.txt");
await files.restoreVersion("notes.txt", previous.versionId); // back to "v1"
```

:::note
`versions()` and `restoreVersion()` are contributed by the plugin's `extend`, so they only appear on the **type** when you construct with [`createFiles`](/docs/plugins/api#createfiles) (identical to `new Files()` at runtime). `versioning()` is the first built-in plugin to use `extend`.
:::

## How it works

Every snapshot is a plain object **copy**, not a re-upload:

1. Before a write would overwrite or delete a key, the plugin `head`s it. If nothing's there (a first write), there's nothing to snapshot and it moves on.
2. Otherwise it copies the current object to `"<prefix>/<key>/<versionId>"` - the default prefix is `.versions`, so a version of `photos/a.jpg` lands at `.versions/photos/a.jpg/<versionId>`.
3. The live write then proceeds as normal.

The `versionId` is the time the snapshot was taken and the object's own last-modified time (both zero-padded so ids sort chronologically), plus a slug of its ETag, so versions list newest-first and stay unique per change. Ordering by snapshot time rather than the object's is deliberate: a native move (the local filesystem, memory, FTP, SFTP) carries the source's older last-modified time onto the destination, so the object's time can disagree with the order the key actually changed in. The snapshot time comes from the app's clock, so instances writing the same key need reasonably synchronized clocks. Version ids written before this format still parse and sort as older than every newer one.

Because snapshots are copies of whatever is **already stored**, the plugin composes cleanly with the transforming plugins: a version of an encrypted object is still encrypted (the wrapped key rides along in its metadata) and restores to readable plaintext.

## Restoring

`restoreVersion(key, versionId?)` copies a version back over the live key. Omit the `versionId` to restore the **newest** version - an undo of the last change:

```ts lineNumbers
await files.upload("report.pdf", v1);
await files.upload("report.pdf", v2); // overwrites; v1 is snapshotted

await files.restoreVersion("report.pdf"); // back to v1
```

A restore **snapshots the current bytes first**, so it's itself reversible - you can always roll forward again. It resolves to the restored object's [`FileInfo`](/docs/api/stored-file). Restoring works after a delete too, since the delete was snapshotted:

```ts lineNumbers
await files.delete("report.pdf");
await files.restoreVersion("report.pdf"); // undeletes it
```

## Listing history

`versions(key)` returns the saved versions **newest-first**, each with the `versionId` you pass to `restoreVersion()`:

```ts lineNumbers
const history = await files.versions("report.pdf");
// [
//   { versionId, key: ".versions/report.pdf/…", size, lastModified, etag? },
//   …
// ]
```

Each entry's `lastModified` is the snapshotted object's own last-modified time. The list is ordered by when each version was snapshotted, so after a native move those times aren't necessarily in order.

The `key` on each entry is a real, downloadable object, so you can preview a version without restoring it: `await files.download(history[0].key)`.

`restoreVersion()` refuses a `versionId` that is empty or `.`, or that contains `/`, `\`, `..`, or a NUL byte, with an `Invalid` error: a version id is a single name, and anything else could address another key's history (on a Windows filesystem, `..\` climbs into a sibling's version folder). Omit the id to restore the newest version.

## Capping history

By default history grows unbounded. Set `limit` to keep only the newest N versions per key - the oldest are pruned once the write that took the snapshot succeeds (a failed write prunes nothing, so history can briefly exceed the cap until the next successful write):

```ts
versioning({ limit: 20 });
```

A `restoreVersion()` under a `limit` prunes only **after** the restored bytes have landed, so restoring the oldest kept version (or the only one, with `limit: 1`) always works - the restore's own snapshot of the current bytes is what takes the freed slot.

## Choosing the prefix

Snapshots live under `.versions` by default. Override it with `prefix`, and keep your own data out of it. Behind the [gateway](/docs/ui/server/authorization), this prefix is reserved: a client key or `list`/`search` prefix inside it gets `403`, so browsers reach history only through `versions()` and `restoreVersion()`:

```ts
versioning({ prefix: ".history" });
```

Objects under the version prefix are **hidden from [`list()`](/docs/api/list)** so snapshots don't clutter your listings - unless you explicitly list within the prefix (which is how `versions()` reads them). Filtering preserves the page `cursor`, so [pagination](/docs/api/list) still resumes correctly; pages may just come back shorter.

## Leaving prefixes un-versioned

Pass `ignore` to skip snapshots for keys under the given prefixes, on top of the version prefix itself. Writes and deletes there pass straight through. A key counts as under a prefix only when it's spelled exactly that way and stays inside once resolved, so `.versions/../notes.txt` (which a filesystem resolves to `notes.txt`) is still snapshotted:

```ts
versioning({ ignore: [".trash"] });
```

Its main use is keeping another plugin's housekeeping out of the history - see [pairing with soft delete](#pairing-with-soft-delete) below.

## Ordering

Versioning operates on logical keys and snapshots whatever the rest of the pipeline stored, so place it **first** (outermost):

```ts
plugins: [versioning(), compression(), encryption(key)];
```

Guards that vet the caller's writes - [`contentType()`](/docs/plugins/content-type) and [`validation()`](/docs/plugins/validation) - go in front of it. Its snapshot copies only pass through the plugins after it, so a `validation()` key rule placed after `versioning()` would reject the `.versions/...` keys and break every overwrite and delete.

## Pairing with soft delete

Version history and a recycle bin are a common pair, and both plugins can sit on one instance. Put `versioning()` **before** [`softDelete()`](/docs/plugins/soft-delete) and hand it the trash prefix as `ignore`:

```ts lineNumbers
const files = createFiles({
  adapter: r2({ bucket: "uploads" }),
  plugins: [versioning({ ignore: [".trash"] }), softDelete()],
});

await files.delete("report.pdf"); // snapshotted, then moved to the trash
await files.restoreTrashed("report.pdf"); // back from the trash
await files.restoreVersion("report.pdf"); // or roll back to the last version
await files.purge("report.pdf"); // after a delete: bytes really leave storage
```

The order matters. With versioning outermost, a `delete` is snapshotted **and** trashed, so the last bytes survive a purge as a version. The other way round, versioning only sees the move into the trash and snapshots nothing, so a `purge()` destroys the last write for good.

The `ignore` matters too. A `purge()` is a real `delete` of the trash key, which versioning would otherwise snapshot into `.versions/.trash/…` - a copy nothing lists and nothing reclaims. Ignoring the trash prefix keeps a purge a purge.

With a `limit`, the versions it prunes are deleted through `softDelete()` too, so they land in the trash under `.trash/.versions/…`, show up in `trashed()` (as `.versions/…` keys), and take up storage until you `purge()` them.

## Things to keep in mind

- **A `head` + `copy` per overwrite/delete.** Snapshotting adds two adapter round-trips to writes that hit an existing object; first writes cost only the `head`. It's the price of keeping history.
- **Direct presigned writes bypass it.** A client `PUT` to a [`signedUploadUrl`](/docs/api/signed-upload-url) never runs the plugin, so no snapshot is taken. Write through the instance to version. It's a safety net, not a security control, so - unlike [`validation()`](/docs/plugins/validation) - it doesn't fail closed.
- **`move` snapshots only its destination.** A rename relocates the bytes rather than destroying them, so the source isn't snapshotted; the data lives on at the new key. A `copy` or `move` of a key onto itself changes nothing, so it takes no snapshot.
- **[Conditional](/docs/conditional-operations) writes are refused.** A snapshot can't share the native compare-and-set, so conditional uploads, deletes, and copies throw before any I/O, and [`files.capabilities`](/docs/capabilities) reports those `conditional` flags as `false`. Exact reads still pass through.
- **History is unbounded** unless you set `limit`.
- **Don't store your own data under the version prefix.** Writes there are passed through un-versioned and hidden from `list()`.
