Skip to content
Files SDK
Esc
↑↓navigate↵open⌘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 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:

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? },
//   …
// ]

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

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

Guards that vet the caller’s writes - contentType() and 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() 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.

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 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. A copy or move of a key onto itself changes nothing, so it takes no snapshot.
  • Conditional 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 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().

Was this page helpful?