No object versioning? Give avatars a rollback window instead
Overwriting a key destroys the old bytes for good. A retention window, a pointer row and a prune job give you rollback without version IDs.
Rollback has to be something your application keeps, not something the bucket remembers. Overwriting avatars/usr_5540/current.webp replaces the bytes in place — the old ones are gone, and no version_id parameter exists anywhere in the Infrai storage API to get them back. What works instead is boring and reliable: write each upload to its own immutable key, point a database column at the newest one, and delete the losers on a schedule you choose.
That gives you the thing versioning was for — “put it back the way it was” — with an explicit retention window you can reason about and a bill you can predict.
What overwrite actually does, measured
Here’s the demonstration, because people reasonably want proof before they redesign an upload path. Write one object, read its head, overwrite the same key, read again:
{
"before": { "found": true, "key": "u/usr_7781/avatar.webp", "size_bytes": 16, "etag": "5232144119273369702a978c1059801f", "last_modified": "2026-07-26T00:31:59Z" },
"after": { "found": true, "key": "u/usr_7781/avatar.webp", "size_bytes": 34, "etag": "56b08624a471936a54d39fb36ba7b783", "last_modified": "2026-07-26T00:33:56Z" }
}
New ETag, new size, same key. A listing of that prefix still returns exactly one object, because there’s no noncurrent copy hiding behind it and nothing to restore from — the write went through to the vendor and the previous bytes were released the moment it completed. The one recovery path left is last night’s backup, which is a bad answer for a profile picture somebody replaced by accident four minutes ago.
The layout that makes rollback a database update
One prefix per user, one immutable key per upload, and a pointer that decides which is live:
CREATE TABLE avatar_versions (
id bigserial PRIMARY KEY,
user_id uuid NOT NULL,
object_key text NOT NULL UNIQUE,
etag text,
size_bytes bigint,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX avatar_versions_user_idx ON avatar_versions (user_id, created_at DESC);
ALTER TABLE users ADD COLUMN avatar_key text;
Rolling back is then UPDATE users SET avatar_key = $1 against a row that already exists. No bytes move, nothing is copied, and the operation is instant and reversible — which is exactly the property S3 versioning sells, reconstructed in a table you can query.
Writing a new version, then pruning the old ones
The upload writes a content-addressed key, records it, repoints the user, and deletes anything past the retention window in a single batch call:
import { createHash } from "node:crypto";
import { Pool } from "pg";
const API = "https://api.infrai.cc";
const BUCKET = "kb-avatar-hist-0726";
const KEEP = 3;
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function publishAvatar(userId, webpBuffer) {
const digest = createHash("sha256").update(webpBuffer).digest("hex").slice(0, 12);
const key = `avatars/${userId}/${digest}.webp`;
const slotRes = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${key}`, {
method: "POST",
headers: auth,
body: JSON.stringify({ op: "put", expires_seconds: 300, content_type: "image/webp", max_bytes: 2_000_000 }),
});
const slot = await slotRes.json();
if (!slotRes.ok || slot.ok === false) throw new Error(slot?.error?.code ?? `HTTP ${slotRes.status}`);
const put = await fetch(slot.data.url, { method: slot.data.method, headers: slot.data.headers ?? {}, body: webpBuffer });
if (!put.ok) throw new Error(`upload rejected: HTTP ${put.status}`);
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(
"INSERT INTO avatar_versions (user_id, object_key, size_bytes) VALUES ($1,$2,$3) ON CONFLICT (object_key) DO NOTHING",
[userId, key, webpBuffer.length],
);
await client.query("UPDATE users SET avatar_key = $1 WHERE id = $2", [key, userId]);
const { rows } = await client.query(
`SELECT object_key FROM avatar_versions
WHERE user_id = $1 ORDER BY created_at DESC OFFSET $2`,
[userId, KEEP],
);
await client.query("COMMIT");
if (rows.length) await prune(rows.map((r) => r.object_key));
return key;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
async function prune(keys) {
const res = await fetch(`${API}/v1/storage/object/delete_batch/${BUCKET}`, {
method: "POST",
headers: auth,
body: JSON.stringify({ keys }),
});
const json = await res.json();
if (!res.ok || json.ok === false) console.warn("prune failed", json?.error?.code ?? res.status);
else if (json.data.errors?.length) console.warn("prune partial", json.data.errors);
}
Order matters here. The bytes go up first, the pointer moves second, the delete happens last — so a crash anywhere in that sequence leaves an orphan object rather than a user whose avatar 404s, and an orphan is something a weekly sweep can find by comparing a prefix listing against the avatar_versions table. Orphans are cheap. Broken pointers are a support ticket.
Inspecting the window
Three keys under one user’s prefix, newest live, two rollback candidates:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/storage/object/list/kb-avatar-hist-0726?prefix=avatars/usr_5540/" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{ "key": "avatars/usr_5540/7ae0b45c19df.webp", "size_bytes": 40, "etag": "d10f432fa76494f8747f92f32c457a59", "last_modified": "2026-07-26T00:45:22Z" },
{ "key": "avatars/usr_5540/9f86d081884c.webp", "size_bytes": 27, "etag": "05b2d49fd678888d6d948a14b703a8f2", "last_modified": "2026-07-26T00:36:21Z" },
{ "key": "avatars/usr_5540/c1d4f8a20b3e.webp", "size_bytes": 33, "etag": "b0e2dea3817f1fbfdd6621e4e01f12c9", "last_modified": "2026-07-26T00:45:21Z" }
],
"next_cursor": null
}
}
If you must keep a single stable key
Some clients hard-code one path, and changing them isn’t on the table. In that case, snapshot before you overwrite:
curl -sS -X POST https://api.infrai.cc/v1/storage/object/copy \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"src_bucket":"kb-avatar-hist-0726","src_key":"avatars/usr_5540/current.webp","dst_bucket":"kb-avatar-hist-0726","dst_key":"archive/usr_5540/2026-07-26T00-45-22.webp"}'
A copy is a billable write and the bytes never leave the storage layer, so it’s fast and cheap even for large objects. Then let a lifecycle rule bound the archive rather than trusting a cleanup script to run forever:
curl -sS -X POST https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-avatar-hist-0726 \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"rules":[{"prefix":"archive/","expire_days":30}]}'
Lifecycle rules replace the whole set rather than merging into it, so send every rule you want to keep in one request.
How this compares to real versioning
| Approach | Rollback | Storage cost | Watch out for |
|---|---|---|---|
| Immutable key + pointer (here) | Update one row | Bounded by your KEEP | Orphans if a crash lands between steps |
| Copy-before-overwrite | Copy the archived key back | One extra write per change | Archive grows without a lifecycle rule |
| Amazon S3 versioning | Restore a version_id | Every noncurrent version, until a rule expires it | Silent bill growth; delete markers confuse listings |
| GCS object versioning | Restore a generation | Same, priced per generation | Same trap, different noun |
| Overwrite in place, no plan | None | Lowest | The bytes are simply gone |
Infrai has no support for version IDs. If your compliance story requires immutable, provider-enforced history — write-once retention that an administrator with a valid key still cannot defeat, which is what an auditor is usually asking about when they say “versioning” — then S3 Object Lock, GCS retention policies or a self-hosted MinIO with locking are the honest answers, and you should stick with one of them for that bucket. Everything else is a policy your own code enforces, which is fine right up until the moment somebody is auditing the code. For profile pictures, a three-deep window costs about 36 KB per user. It covers every real “undo that” request we’ve seen.
What the retention window costs
The prune loop is free: delete_batch, list and head are all free and rate-limited, and they don’t consume the trial credit. You pay for writes and for what stays stored. Verified 26 July 2026, storage.object.put and storage.object.copy are $0.0001 per call and storage.object.get $0.0002, with stored GB-months and egress metered separately. Keeping three 12 KB versions per user across 50,000 users is about 1.8 GB — the arithmetic that matters is bytes, not calls.
curl -sS https://api.infrai.cc/v1/discovery \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"
Rates move down over time and campaigns run, so check the live figure before committing it to a spreadsheet. The part that holds regardless: the same key running this prune also runs the cron that triggers it, the queue behind the resize, and the per-tenant usage query that tells you which customer’s uploads are growing fastest.