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 restoreVersion() - server-side, body-transparent.

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 restoreVersion() - 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.restoreVersion("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

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:

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

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():

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 });

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:

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.

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:

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

Its main use is keeping another plugin’s housekeeping out of the history - see 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):

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

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() and hand it the trash prefix as ignore:

const files = createFiles({
  adapter: s3({ 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.

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?