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.
versions
Section titled “versions”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)endstore:versions(key, query) returns a list of { version, at, deleted }:
versionis the version’s id, a string, forreadVersionandrestore;atis when it was written, in Unix milliseconds;deletedis 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. |
readVersion
Section titled “readVersion”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).
restore
Section titled “restore”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, ", ")}`)endstore: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. |
Refused while in use
Section titled “Refused while in use”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.
Support workflow
Section titled “Support workflow”-
Find the player’s key and list its versions with
store:versions. Pick one by its time. -
Check it with
store:readVersionbefore you restore it. Restore soon after: while the player plays in the same UTC hour, that hour’s version is replaced. Ifrestoreanswers"noVersion", list the versions again. -
Kick the player. The server that holds the profile releases it when the player leaves (the game’s
PlayerRemovinghandler callsprofile:release()). -
Call
store:restore. If it returns"inUse", the release has not landed yet: wait a few seconds and try again. -
Make good every purchase in
purchasesSince. -
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.