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"

versions() and restore() are contributed by the plugin's extend, so they only appear on the type when you construct with 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 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().

On this page