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

ApproachRollbackStorage costWatch out for
Immutable key + pointer (here)Update one rowBounded by your KEEPOrphans if a crash lands between steps
Copy-before-overwriteCopy the archived key backOne extra write per changeArchive grows without a lifecycle rule
Amazon S3 versioningRestore a version_idEvery noncurrent version, until a rule expires itSilent bill growth; delete markers confuse listings
GCS object versioningRestore a generationSame, priced per generationSame trap, different noun
Overwrite in place, no planNoneLowestThe 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.

References

Browse more storage developer guides