Recovery
Version history, resetting a profile, and deleting one properly.
History
Roblox keeps 30 days of versions for every datastore key, and History lists them.
local Rows, Why = Store:History(UserId, 25):Wait()
if Rows == nil then
warn(`could not list that history: {Why}`)
return
end
for _, Entry in Rows do
print(Entry.Version, os.date("%c", Entry.At), Entry.Deleted)
endYou get back newest first. Version is the string you hand to PeekVersion, At is a Unix
timestamp in seconds, and Deleted says whether that version was a delete.
The limit is clamped between 1 and 100 and defaults to 25.
PeekVersion
local Was = Store:PeekVersion(UserId, Entry.Version):Wait()
if Was then
print(Was.Gold)
endThis folds that old record the same way a load would, so migrations run and you get real state, not raw storage.
It's read only, and there is no restore. Writing an old snapshot back over a live profile would wipe whatever happened in between, including money that arrived from a transfer. To roll something back, look at what changed and write ops that undo it.
Reset
Reset puts a key back to Default.
local Ok, Why = Store:Reset(UserId):Wait()It's an op like any other, so it goes in the log and every server sees it. It keeps _Received and
_Held across. Wiping the applied id set would let an old retry apply again, and wiping the holds
would destroy money that is part way through a transfer.
A server won't reset a record written at a version it doesn't know, so you can't accidentally downgrade a profile during a deploy.
It can answer Unresolved if a stuck transaction leg on that key would
change the outcome. Ask again. The reset has not failed.
Erase
Erase throws the record away. This is your GDPR button.
local Gone, Why = Store:Erase(UserId):Wait()
if not Gone then
warn(`that key is still there: {Why}`)
endCheck the answer. If you are erasing to satisfy a deletion request, false means it did not happen
and you have not finished the job.
First it checks for money this key set aside for a transfer that has not finished and hands it over, so a normal erase doesn't destroy anything that was on its way out. If it can't finish the handover it warns with how much was lost, and you'll have to pay the receivers back yourself.
Money arriving is a different problem, because another server can be delivering to this key while you erase it. So an erase leaves a tombstone in place of the record instead of removing the key. Anything sent to a tombstoned key is refused and the sender keeps the money set aside.
The tombstone lasts 8 days. A write to the key does not clear it. That is the point: the erase has to outlast anything still in flight to the key, and a session on another server has no idea the erase happened. By the end of the window no transfer can still be unfinished.
A key can still be written to and read while it holds a tombstone. It folds from Default like a
fresh profile. Only money sent to it is turned away.
The tombstone holds a timestamp, your Default, and the applied names the key had built up. It holds
no other player data. To remove the key outright, call Erase again after the 8 days.
Keeping the names matters more than it sounds. ProcessReceipt retries until you answer
PurchaseGranted, and Roblox can retry one you already granted. If an erase dropped the names, that
retry would read as never granted and pay out a second time. So a read of an erased key comes back as
a fresh profile, and a receipt already paid out is still refused.
The refund to a turned away sender is not immediate. Their money stays set aside until the transfer
is overdue, which is the same 8 days, and then the recovery sweep gives it back. Forcing
RecoverTransfers on the sender before that does nothing, on purpose: a refund paid early could
still be delivered afterwards and pay twice.
Erasing a player who's still in the server warns. Unload them first:
Store:Unload(Player)
Store:Erase(Player.UserId):Wait()A session on another server knows nothing about the erase, and it cannot bring the key back. A
session is fenced to the tombstone as it stood when it took the key. Its writes are turned away and it
closes itself. Flush, Release and Commit answer Refused rather than
report a save that never happened.
Whatever that session had queued dies with it, so get the player off every server before you erase them. Ledger warns on the erased key when a write arrives, which tells you somebody is still holding it.
The erase is still a version, so History shows it and the data is recoverable through PeekVersion
for 30 days. Roblox keeps that history whatever Ledger does, so know about it before you erase
someone for a legal reason.
Sweeping
Ledger runs a background sweeper that finishes stranded transfers, settles stuck transaction legs, and tidies up old transaction markers. It picks up keys it saw a problem on, so most of the time it sorts itself out and you never notice.
Ledger.Sweep() forces a pass right now. It's useful in a test, or in a live incident when you want
something dealt with immediately instead of on the next tick.
Support tooling
The store methods that take a key all work on players who aren't here, so a support tool doesn't need the player online:
local State, Why = Store:Peek(UserId):Wait()
if State == nil then
warn(`could not read that profile: {Why}`)
return
end
Store:Edit(UserId, "GrantItem", {
Item = "Sword",
Once = `support:{TicketId}`,
}):Wait()
local Applied = Store:DidApply(UserId, `support:{TicketId}`):Wait() == truePut a Once name on anything a support tool writes. Someone will click the button twice.
It stops the double click, and it stops a retry minutes or days later. It does not stop the same ticket being fulfilled again months later, because the name is only remembered for a rolling 30 days. If a reopened ticket has to stay fulfilled for good, record that in the state your reducer can see and refuse on it there. See how long a name is remembered.
Editing storage directly
A datastore editor plugin shows you Ledger's record, not the player's data:
{
Snapshot = { Gold = 100 }, -- the state as of the last compaction
Ops = { ... }, -- changes since then, not folded in yet
Seen = { ... }, -- op ids already applied
Version = 3, Floor = 1, Envelope = 1
}The number someone came looking for is inside Snapshot, and on its own it is not the current value,
because everything in Ops still replays over it.
Replacing the value with a plain state table wipes the player. With no Snapshot field, Ledger
folds from your Default and reads them as a brand new profile. What you typed sits on the record
as a field nothing looks at, until the next compaction drops it.
The rest, in the order they cost you:
- Editing
SnapshotwhileOpshas anything in it. Your edit goes in, then the ops replay over the top. Set gold to 1000 with a pending spend of 50 and the fold answers 950. - Deleting
Seen. That is the dedupe window for ops already compacted away, so an old retry can apply a second time. - Removing an op that has a
Txfield. That is a parked transaction leg. The marker still counts it, so the transaction can half apply or sit stuck until it is reaped. - Clearing
_Heldor_ReceivedinsideSnapshot._Heldis money set aside for a transfer that has not finished, so deleting it destroys that money._Receivedis the delivery evidence, so deleting it lets the same transfer pay twice. - Raising
Envelopeis the one safe mistake. Every server answersBehindand refuses to touch the record rather than misreading it.
Use the api instead. It goes through your reducer and the log rather than around them, and it works whether or not the player is online or on this server:
Store:Inspect(UserId):Wait() -- see what is actually on the key first
Store:Edit(UserId, "GrantItem", { Item = "Sword", Once = `support:{TicketId}` }):Wait()
Store:Reset(UserId):Wait() -- back to Default, keeping _Received and _HeldReset keeps the two reserved fields, which hand editing would not. Once makes the double click
safe.
One last thing if you do edit storage. A live session does not see it until its next autosave refold, so up to 30 seconds, and the ops it has already queued were worked out against the state before your edit.