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:
- 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. - Otherwise it copies the current object to
"<prefix>/<key>/<versionId>"- the default prefix is.versions, so a version ofphotos/a.jpglands at.versions/photos/a.jpg/<versionId>. - 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 v1A 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 itListing 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+copyper overwrite/delete. Snapshotting adds two adapter round-trips to writes that hit an existing object; first writes cost only thehead. It's the price of keeping history. - Direct presigned writes bypass it. A client
PUTto asignedUploadUrlnever runs the plugin, so no snapshot is taken. Write through the instance to version. It's a safety net, not a security control, so - unlikevalidation()- it doesn't fail closed. movesnapshots 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().