Skip to content
Files SDK
Esc
navigateopen⌘Jpreview
On this page

versioning

Snapshot an object's prior bytes before every overwrite or delete, and roll a key back with versions() and restore(). Server-side copies under a hidden prefix - body-transparent, no native dependencies, works on any adapter.

The built-in versioning() plugin keeps a history of every object. Before an upload, delete, or the destination of a copy / 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 restore() - let you list that history and roll a key back.

Unlike encryption() and compression(), it’s body-transparent: it never buffers, transforms, or even reads the body, so streaming, range downloads, url(), and signedUploadUrl() all keep working. It has no native dependencies and works on any adapter.

import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { versioning } from "files-sdk/versioning";

const files = createFiles({
  adapter: s3({ 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.restore("notes.txt", previous.versionId); // back to "v1"

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 heads 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 object’s last-modified time (zero-padded so ids sort chronologically) plus a slug of its ETag, so versions list newest-first and stay unique per change.

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

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

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

await files.restore("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 StoredFile. Restoring works after a delete too, since the delete was snapshotted:

await files.delete("report.pdf");
await files.restore("report.pdf"); // undeletes it

Listing history

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

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

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

Capping history

By default history grows unbounded. Set limit to keep only the newest N versions per key - the oldest are pruned after each snapshot:

versioning({ limit: 20 });

Choosing the prefix

Snapshots live under .versions by default. Override it with prefix, and keep your own data out of it:

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

Objects under the version prefix are hidden from 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 still resumes correctly; pages may just come back shorter.

Ordering

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

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

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 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() - 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.
  • 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().

Was this page helpful?