Skip to content

Versions and rollback

Roblox keeps one version of a key per UTC hour, for 30 days (Roblox docs: versioning). A later write in the same hour replaces that hour’s version, so a version id listed a moment ago may be gone once the player plays on (seen live). KeepBlox lets a support tool list those versions, read one, and restore one.

const list = store:versions(`u_{userId}`, { newestFirst = true, limit = 20 })
for _, version in list do
print(version.version, DateTime.fromUnixTimestampMillis(version.at):ToIsoDate(), version.deleted)
end

store:versions(key, query) returns a list of { version, at, deleted }:

  • version is the version’s id, a string, for readVersion and restore;
  • at is when it was written, in Unix milliseconds;
  • deleted is true for a version that marks the key removed. A removed key stops counting toward the experience’s storage at once, but the versions before the removal stay listed and readable for 30 days (checked live).
Query field Meaning
from, to Bounds on at, in Unix milliseconds. 0 or nil means no bound.
newestFirst Newest first. Default: oldest first.
limit Stop after this many. Default 100.
const data = store:readVersion(`u_{userId}`, version)

store:readVersion(key, version) returns the profile data the key held at that version, or nil when the key has no such version now (it was replaced later in its UTC hour, or the id is not well formed) or that version is not a profile. A version stored compressed is returned decompressed (see Large profiles).

const result = store:restore(`u_{userId}`, version)
if not result.ok then
warn(`restore refused: {result.reason}`)
elseif #result.purchasesSince > 0 then
warn(`these purchases were rolled back and must be made good: {table.concat(result.purchasesSince, ", ")}`)
end

store:restore(key, version) makes that version’s data current. It writes through the lock, in one UpdateAsync, and changes only the data: the record’s lock, its queued messages and its purchase receipt ids stay as they are now. So a restore never replays a message or a purchase.

That has a cost: a purchase granted after the restored version is gone from the data, and it stays granted, so Roblox never asks for it again. The result names those purchases in purchasesSince, the purchase ids granted since that version, oldest first. Make each of them good (grant it again by hand, or refund it). Only ids still in the kept purchase history are known.

The record also notes the restore in MetaData.KeepBlox.restored = { from = version, at = time }, so a later look at the key shows it was rolled back.

It returns { ok = true, purchasesSince = { ... } } or { ok = false, reason = ... }:

Reason When
"inUse" A server holds the key. Nothing is written.
"notAProfile" The version, or the key’s current value, is not a profile record.
"noVersion" The key has no such version now. Roblox keeps one version per UTC hour, so a version listed a moment ago is gone once the key is written again in that hour; an id that is not well formed names none.
"failed" A call did not succeed.

A restore refuses while any server holds the key, so it never races a live session. A live session would otherwise overwrite the restored data at its next save, or keep playing on data that no longer matches the store.

  1. Find the player’s key and list its versions with store:versions. Pick one by its time.

  2. Check it with store:readVersion before you restore it. Restore soon after: while the player plays in the same UTC hour, that hour’s version is replaced. If restore answers "noVersion", list the versions again.

  3. Kick the player. The server that holds the profile releases it when the player leaves (the game’s PlayerRemoving handler calls profile:release()).

  4. Call store:restore. If it returns "inUse", the release has not landed yet: wait a few seconds and try again.

  5. Make good every purchase in purchasesSince.

  6. Let the player rejoin. The next load starts from the restored data.

A key whose server died still names that server as its holder, so a restore refuses it. A quiet load takes such a key over once its owner is proven dead, without kicking anyone from a live server: store:load(key, { quiet = true }) fails with "inUse" while the owner lives. Release the profile it returns, then restore.