Skip to content

Version history

Restores a corrupt DataDriver record from Roblox's native DataStore versions, and stages the version current at a given time for support rollbacks without writing until the caller commits.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.Data
Since
v0.9.0
Source
Data/init.luauBackends/DataStoreBackend.luau

Roblox keeps free versions of every DataStore key. Version history is DataDriver’s use of them in two places: when a record exists but will not decode, the load walks the versions newest first and restores the first one that passes, and driver:restoreAt(key, unixMs) puts a key back to a moment for support tooling. The defining decision is that a version never gets a shortcut. A restore goes through the same locked, checked load path as any other load, and restoreAt only stages the old data in memory, writing nothing until the caller saves or releases, or drops it with discard(). Backups are the paid alternative for retention you control; the DataStore backend supplies the version reads.

Versions are hourly. The first write to a key in each UTC hour creates a version, and later writes in that hour overwrite it, so a key has at most one version per hour. Roblox expires an overwritten version after 30 days. Earlier docs said every write is a version; that was never true.

Two consequences shape the driver:

  • The newest version can be up to an hour behind the last save. A Backup mirror slot, copied on every save, is usually fresher, and the fallback prefers it when it is.
  • restoreAt has hour granularity: every write inside one UTC hour shares a version.

Versions cost nothing. Backup keys are separate keys and count toward billed storage.

The fallback runs only when a load fails with a recoverable error: the record exists and is writable, but the envelope is malformed, its data will not decode, is not a table, or holds values that cannot be stored. Every other failure fails closed (see What fails closed).

It is on when the backend supports versions (only the DataStore backend sets SupportsVersions = true) and Versions.Fallback is not false. When Versions.Fallback is unset, Backup.Fallback = false turns it off too. Since 0.9.0 the fallback is on by default for DataStore profiles.

The walk:

  1. When Backup is configured with its fallback on, the driver reads the newest readable backup first, to know how old it is.
  2. It lists up to MaxVersions (default 10) versions, newest first, skipping deleted ones (ListVersionsAsync).
  3. For each version it reads the value (GetVersionAsync) and builds a candidate. A candidate is skipped when its envelope is malformed, when it was written mid-trade (its PendingTransaction journal pointer cannot be carried by a restore), when no migration path leads to the current SchemaVersion, or when its data does not decode or is not a table.
  4. If the newest readable candidate is older than the newest backup, the walk stops and the backup is restored instead.
  5. Otherwise the candidate is loaded as the record: the same lock check, migrations, reconcile and Validate as a live load, written over the primary inside the same atomic transform. A candidate that Validate or a migration rejects is skipped for the next older one. A held lock or a backend outage ends the walk, because no older copy fixes either.
  6. When no version restores, the driver tries the newest backup. Without one, the load fails with the original error and the version walk’s error appended.

A restored profile has RestoredFromBackup = true, Meta.RestoredFromSlot = "version <id>" and Meta.RestoredFromBackupAt set to the restore time. It keeps the version’s own backup schedule, replay ids, UserIds and reconcile ledger (Meta.DefaultPaths), so default entries the record had removed stay removed. The driver warns <key> restored from version <id> (<n>s old), and the attached lifecycle publishes Kernel.ProfileRestored(session, profile) after Kernel.ProfileLoaded, so the game can tell the player or grant make-goods.

driver:restoreAt(key, unixMs, options?) loads key with the version that was current at a Unix time in milliseconds. It exists for support: a player reports a loss, and an operator rolls their record back to before it happened.

  1. It reads the version current at unixMs (GetVersionAtTimeAsync). No version then returns no version was current at that time; an unreadable one returns the version at that time is unreadable: ....
  2. It takes the session lock over the current live record with a normal load. Every load rule applies: another server’s lock fails the call, and so does a key already loaded on this server. If the live record itself is unreadable, the version is written over it directly through the restore path.
  3. It stages the old data. The version is migrated to the current SchemaVersion, missing Defaults fields are filled in following the version’s own ledger (entries it had removed stay removed), and the result is copied into profile.Data in place. The live record is unchanged. A version that does not migrate releases the live profile untouched and returns the version at that time does not migrate: ....
  4. It returns the profile with RestoredFromBackup = true and Meta.RestoredFromSlot = "time <unixMs>", and warns <key> staged the version current at <unixMs>; save or release commits it, discard drops it.

Only profile:save() or profile:release() commits the staged data, through the usual save checks. profile:discard() releases the lock and leaves the live record as it was. Until the caller decides, an attached driver’s autosave passes only refresh the staged profile’s lock, and the shutdown drain discards the staging. Before 0.9.1 the next autosave committed it. If another server took the lock meanwhile, the refresh ends the staging through the lost-lock path, like a save: the profile goes inactive and Data.LockLost publishes. A discard() whose write failed is still a discard: save() refuses to commit the staging after it, and discard(), release() or the next autosave finishes it. Both behaviors come from the Unreleased fixes after 0.9.1.

-- Support tooling: roll an offline player's record back to a moment, once support confirms the old copy.
local function rollBack(userId: number, unixMs: number, confirm: (data: any) -> boolean): (boolean, string?)
local Profile, Err = PlayerData:restoreAt(`Player_{userId}`, unixMs, { UserIds = { userId } })
if not Profile then
return false, Err
end
-- The old data is staged in memory. Release commits it; discard keeps the live record. Both free the lock.
if not confirm(Profile.Data) then
return Profile:discard()
end
return Profile:release()
end
local Ok, Err = rollBack(123456, DateTime.fromUniversalTime(2026, 9, 27, 18, 0, 0).UnixTimestampMillis, function(data)
-- The player reported losing their Dragon: only roll back to a copy that has it
return table.find(data.Pets, "Dragon") ~= nil
end)

A version or backup is never restored over data that might be newer or owned elsewhere:

Situation Result
Another server holds the lock The load fails. Corrupt does not mean unlocked.
The backend is down The load fails. Loading without a lock is a duplication path.
The live record has a newer SchemaVersion The load fails. Old code must not run over newer data.
Validate or a migration rejects the live record The load fails. The rules changed; an older version would roll the player back.
A migration or Validate yields The load fails. The same code would fail on every version.
A version was written mid-trade That version is skipped.
A version is older than the newest backup The backup is restored instead.
Field Default Description
Versions.Fallback true Restore the newest readable version when the record is corrupt. Defaults to false when Backup.Fallback = false.
Versions.MaxVersions 10 Versions tried per fallback, newest first.
Member Description
driver:restoreAt(key, unixMs, options?) -> (Profile?, err?) Stages the version current at unixMs (Unix milliseconds, asserted positive) over the locked live record. options: { UserIds?, HandoffSeconds? }. The caller owns the profile and ends the staging with save(), release() or discard().
profile:discard() -> (ok, err?) Drops a restoreAt staging: the lock is released and the stored record stays as it was. Returns false, "nothing staged: only a profile from restoreAt can be discarded" on any other profile, and true on an inactive one. When the release write fails, it returns false and the profile stays discarding: a later discard(), release() or autosave finishes it, and save() refuses with the staged restore was discarded; discard() or release() finishes it. Added in 0.9.1.
backend:versions(key, limit) -> (ok, { { Version, CreatedTime } }?, err?) Up to limit versions, newest first, deleted ones skipped. CreatedTime is Unix milliseconds.
backend:readVersion(key, version) -> (ok, value, err?) One version’s stored envelope.
backend:readVersionAt(key, unixMs) -> (ok, value, err?, writtenAtMs?) The envelope current at unixMs, or nil when none was, plus that version’s write time.
backend.SupportsVersions true on the DataStore backend. Turns the automatic fallback on.

The Memory and Http backends implement the three reads and return unsupported: the Memory backend keeps no version history (or the Http equivalent), so restoreAt on them fails with that error and the fallback skips them.

  • A restore can lose up to an hour of progress, because a version holds the last write of its hour. Turn on Backup with Mirror = true when that matters.
  • A version written before the reconcile ledger existed (before 0.9.0) has no Meta.DefaultPaths. A restore of one reconciles like any record from before the ledger, so dictionary entries from Defaults that the game had removed are filled in again, once.
  • In Studio with the scaffold’s Memory backend there is no version history; restoreAt returns the unsupported error.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.