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