soft-delete
Turn delete into a recoverable move into a trash prefix, with trashed(), restore(), and purge(). A server-side move under a hidden prefix - body-transparent, no native dependencies, works on any adapter.
The built-in softDelete() plugin gives you a recycle bin. Instead of destroying an object, delete server-side moves it to a hidden trash prefix; the bytes only ever leave storage when you purge(). Three new methods - trashed(), restore(), and purge() - let you list, recover, and permanently remove what's been deleted.
Like versioning(), 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 { softDelete } from "files-sdk/soft-delete";
const files = createFiles({
adapter: s3({ bucket: "uploads" }),
plugins: [softDelete()],
});
await files.upload("notes.txt", "hi");
await files.delete("notes.txt"); // moved to .trash/notes.txt, not destroyed
await files.trashed(); // [{ key: "notes.txt", trashKey: ".trash/notes.txt", … }]
await files.restore("notes.txt"); // back to "notes.txt"trashed(), restore(), and purge() 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).
How it works
A soft delete is a plain object move, not a re-upload:
- A
deleteof a live keyphotos/a.jpgmoves the object to"<prefix>/<key>"- the default prefix is.trash, so it lands at.trash/photos/a.jpg. - The original key is now gone from listings; the bytes live on under the trash prefix.
- Deleting a key that doesn't exist stays a no-op, exactly like a plain
delete- the move only runs when there's something to move.
Because a move relocates whatever is already stored, the plugin composes cleanly with the transforming plugins: trashing an encrypted object keeps it encrypted, and it restores to readable plaintext.
Restoring
restore(key) moves the trashed copy back over the live key and resolves to the restored StoredFile. Restoring also clears the copy from the trash:
await files.delete("report.pdf");
await files.restore("report.pdf"); // undeletes itIf a live object already exists at key - say you re-created it after the delete - restoring overwrites it. It throws when nothing is trashed for the key.
Listing the trash
trashed() returns everything currently in the trash, each entry carrying the original key you'd pass to restore() or purge():
const items = await files.trashed();
// [
// { key: "report.pdf", trashKey: ".trash/report.pdf", size, lastModified?, etag? },
// …
// ]The trashKey is a real, downloadable object, so you can preview a trashed file without restoring it: await files.download(items[0].trashKey).
Purging
Soft deletes never reclaim storage on their own - that's the point. purge() is the only thing that actually removes bytes. Pass a key to empty one item, or omit it to empty the entire trash:
await files.purge("report.pdf"); // one item
await files.purge(); // the whole trashpurge() is idempotent: purging a key with nothing trashed is a no-op. (Under the hood, a delete of a key inside the trash prefix is a real delete - that's how purging works, and how any manual trash cleanup behaves.)
Choosing the prefix
Trashed objects live under .trash by default. Override it with prefix, and keep your own data out of it:
softDelete({ prefix: ".bin" });Objects under the trash prefix are hidden from list() so deletes don't linger in your listings - unless you explicitly list within the prefix (which is how trashed() reads them). Filtering preserves the page cursor, so pagination still resumes correctly; pages may just come back shorter.
Ordering
Soft delete operates on logical keys and relocates whatever the rest of the pipeline stored, so place it first (outermost):
plugins: [softDelete(), encryption(key)];Things to keep in mind
- One trashed copy per key. A delete moves to
"<prefix>/<key>", so re-deleting a key whose trashed copy still exists replaces that copy (latest delete wins). Reach forversioning()if you need every deleted generation kept. - A delete costs an extra round-trip. A soft delete is a server-side
copy+deleterather than a single delete. It's the price of recoverability. - Direct presigned writes bypass it. Only deletes through the instance are trashed. It's a safety net, not a security control, so - unlike
validation()- it doesn't fail closed. - Trash grows until you
purge(). Nothing expires on its own; wire up your own retention if you need one. - Don't store your own data under the trash prefix. A
deletethere is a real delete, and the objects are hidden fromlist().
signed-url-policy
A fail-safe guard that enforces safe defaults on url() and signedUploadUrl() - force a download disposition, cap expiry, and require a server-enforced upload size limit. Provider-agnostic, no native dependencies, no metadata.
tiering
Route operations between a hot and a cold adapter by size, prefix, or age. Uploads land in the right store, reads transparently find them again, and tier() moves objects between stores - body-transparent, no native dependencies.