Object Versioning

Keep every revision of every file. Restore yesterday's contract, undo an accidental delete, audit who replaced what. Versioning is opt-in per bucket — buckets without it on behave exactly as before.

Why this exists

Most files in most apps don't need history — the avatar a user uploaded last week is fine to lose when they pick a new one. But some files are different. Legal contracts, signed documents, configuration backups, user-authored content — for these, “the current bytes” isn't the whole story. You need to know what was there before, you need to be able to bring it back, and you need to do all of that without buying a different product or wiring up your own snapshot pipeline.

Object versioning gives you that history at the storage layer. Every overwrite preserves the prior bytes. Every delete is soft — recoverable until you explicitly say otherwise. Restore is one call. And because it's opt-in per bucket, your avatars and thumbnails don't pay for storage they don't need.

Enable versioning

Versioning is a per-bucket setting. Flip it on from the dashboard, or via the buckets PATCH endpoint:

curl -X PATCH https://api.koolbase.com/v1/projects/{project_id}/buckets/{bucket_name} \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"versioning_enabled": true}'

From that moment forward, every overwrite of an existing file in the bucket snapshots the prior bytes, and every delete becomes a soft delete. Files uploaded before versioning was turned on don't retroactively get history — they just start tracking from their next overwrite.

Toggling versioning off doesn’t erase history

Setting versioning_enabled back to false stops future overwrites from creating history. Any history rows already in the bucket stay where they are — you can still list, download, restore, or purge them. If you want to wipe a bucket’s history wholesale, purge individual paths first.

The mental model

Versioning is small enough to describe in one paragraph. For each path in a versioned bucket, there's at most one current row — the bytes a normal download returns — plus zero or more history rows. An overwrite snapshots the existing current bytes into history and replaces them in canonical. A delete snapshots the current bytes into history and writes a tombstone row (a “delete marker”) where current used to be. A restore takes a history version, snapshots whatever is currently current, and copies the history bytes back into canonical — restore is itself a versioned event you can undo.

canonical                      history archive
─────────────                  ───────────────
contract.pdf  (v3, current)    contract.pdf @ v2   (size 41kb)
                               contract.pdf @ v1   (size 39kb)

# After delete:
                               contract.pdf @ v4   (delete marker, size 0)
                               contract.pdf @ v3   (size 47kb)
                               contract.pdf @ v2   (size 41kb)
                               contract.pdf @ v1   (size 39kb)

# After restoreVersion("v2"):
contract.pdf  (v5, current)    contract.pdf @ v5'  (the marker, archived)
                               contract.pdf @ v4   (delete marker)
                               contract.pdf @ v3   (size 47kb)
                               contract.pdf @ v2   (size 41kb)  ← bytes copied to current
                               contract.pdf @ v1   (size 39kb)

List and inspect versions

listVersions returns the full timeline for a path, newest-first. Each entry tells you whether it's the current row (isCurrent) and whether it's a soft-delete tombstone (isDeleteMarker).

// Flutter
final versions = await Koolbase.storage.listVersions(
  bucket: 'documents',
  path: 'contract.pdf',
);

for (final v in versions) {
  print('${v.versionId}: size=${v.size} '
        'isCurrent=${v.isCurrent} '
        'isDeleteMarker=${v.isDeleteMarker}');
}
// React Native
const versions = await Koolbase.storage.listVersions(
  'documents',
  'contract.pdf',
);

for (const v of versions) {
  console.log(`${v.versionId}: size=${v.size} ` +
              `isCurrent=${v.isCurrent} ` +
              `isDeleteMarker=${v.isDeleteMarker}`);
}

For a single version by id (when you already know which one you want), use getVersion:

final v = await Koolbase.storage.getVersion(
  bucket: 'documents',
  path: 'contract.pdf',
  versionId: '019e98ed-eed6-7e71-...',
);

What about delete markers?

Delete markers appear in listVersions alongside real versions — they let you reconstruct a complete history (including when the file was deleted). If your UI only wants restorable versions, filter client-side: v => !v.isDeleteMarker.

Download a specific version

getDownloadUrl accepts an optional versionId. Omit it for the current bytes; pass one to download that historical version directly. The returned URL is presigned and valid for one hour, just like a normal download URL.

// Current bytes
final currentUrl = await Koolbase.storage.getDownloadUrl(
  bucket: 'documents',
  path: 'contract.pdf',
);

// A specific historical version
final v2Url = await Koolbase.storage.getDownloadUrl(
  bucket: 'documents',
  path: 'contract.pdf',
  versionId: '019e98ed-eed6-7e71-...',
);

Delete markers have no bytes — calling getDownloadUrl with a marker's versionId throws.

Restore a version

restoreVersion brings a history version back as the current one. Behind the scenes, the existing current bytes are snapshotted into history first — so every restore is itself a versioned event you can undo. The restored row gets a freshly-minted versionId; the target stays in history at its original id.

final restored = await Koolbase.storage.restoreVersion(
  bucket: 'documents',
  path: 'contract.pdf',
  versionId: '019e98ed-eed6-7e71-...',
);

print(restored.path);  // contract.pdf
print(restored.size);  // bytes restored
print(restored.etag);  // fresh etag for the new current row

Restore refuses two cases explicitly: the already-current version (would be a no-op), and a delete marker (you can't restore “deleted” as the current state — if you want to undo a delete, restore the version before the marker).

Undoing a restore

Because restore snapshots the prior current row into history before swapping bytes, you can always restore that snapshot to undo. Restore is symmetric — nothing is ever lost in the process.

Purge versions

Soft history is the default — deletes are recoverable, overwrites preserve bytes. When you need to actually remove data (storage cost, retention policies, GDPR), use one of two explicit purge calls.

Purge a single version

purgeVersion hard-removes one history row and its archived R2 bytes (or just the row, for delete markers). Refuses to operate on the current version.

await Koolbase.storage.purgeVersion(
  bucket: 'documents',
  path: 'contract.pdf',
  versionId: 'old-version-id',
);

Purge an entire path

delete with forcePurge: true wipes the complete timeline for a path — current row, every history row, every R2 key. Use sparingly.

await Koolbase.storage.delete(
  bucket: 'documents',
  path: 'contract.pdf',
  forcePurge: true,
);

Purges are irreversible

Both calls remove data immediately and there's no recycle-bin under them. The explicit forcePurge: true argument is deliberate — a normal delete on a versioned bucket is always recoverable.

Storage counters

Each bucket tracks two byte counters so you can reason about versioning's cost in isolation:

  • current_size_bytes — sum of current rows. What a non-versioned bucket would have used. Returned in the usage endpoint and on the bucket detail view.
  • versioned_size_bytes — sum of history rows. The extra storage cost of versioning, exclusive of current data. Stays at zero for buckets without versioning enabled.

Counters update atomically with each version-creating operation (upload, overwrite, delete, restore, purge). The bucket's max_size_bytes cap, if set, applies to current_size_bytes only — history doesn't count toward the quota.

Behaviors to know

  • Versioning is opt-in per bucket and defaults to off. Buckets you've created before today are unchanged.
  • Toggling versioning_enabled back to false stops future overwrites from creating history but leaves existing history rows intact.
  • Restoring the already-current version throws already_current. Restoring a delete marker throws.
  • Downloading a delete marker throws deleted_version — there are no bytes to fetch.
  • Public bucket URLs and image transforms work against the current row only. To serve a historical version publicly, restore it first (or download via presigned URL and re-upload to a new path).
  • Version IDs are UUIDv7, so they sort lexicographically by creation time — useful for client-side ordering.

Troubleshooting

I enabled versioning but my older files don't show history

Versioning isn't retroactive. Files uploaded before the toggle don't have history rows; they start tracking from their next overwrite or delete in the now-versioned bucket. To force a starting snapshot, overwrite the file with the same bytes once.

restoreVersion returns 409

Most likely you're restoring the already-current version (code already_current) or a delete marker (code deleted_version). The other 409 is versioning_not_enabled — you're calling restore on a bucket without versioning on, which has no history to restore from.

I deleted a file and it's still in listVersions

That's by design. Delete on a versioned bucket is soft — the bytes are snapshotted to history and a marker row goes where the current row was. Filter markers client-side if you want a “restorable versions only” view, or use delete(..., forcePurge: true) if you want the file gone for real.

I purged a version and the bucket size didn't drop

Counters update synchronously, but the dashboard reads them at fetch time. Reload the page or call the usage endpoint to see the new value. If the size is still off, check whether you purged a delete marker (marker rows have size 0 — removing one frees storage_objects rows but not bytes).