Saved data lock
Saved.lock records the shape each saved section promises to existing saves, and shot check reports a change no Version bump and Migrate step covers.
What it is
A saved section's shape is a promise to every player's existing save. Rename, move or retype a key of what a section saves and old saves load wrong, unless the change bumps the section's Version and adds a Migrate step from the old one. Nothing on the screen shows it: scenarios start from fresh profiles. Saved.lock, a plain text file at the project root, records per section and per Version the keys it saves. shot check compares the code with it. With no Saved.lock nothing is checked and nothing is printed.
What is a section
A call Registry.Register(name, { Version = ..., Default = ..., Migrate = ... }) whose table has Version and Default (the registry's name doesn't matter). name is a string or a local set to one. Version is a number, a local set to one or Module.Version (a module field Version : number = 2). Default and Migrate are a local function, Module.Function or an inline function.
The shape is the record type Default returns: its return annotation (function Default() : WalletSave), or the annotated local it returns (local empty : WalletSave = ...; return empty). Its keys are recorded with their types, and the record types they name are recorded inside them: Field.Sub for a record, Field[].Sub for a list of records, Field[K].Sub for a map to records (3 deep).
Not seen: a Default with no annotation (its section is written as "unseen" and not compared), keys a function adds after Default, what Validate reads, and unions or other non-record types (recorded as their text). To have a shape seen, annotate Default's return type with the record type the section saves.
The command
shot savelock [--out file]
Writes Saved.lock from the project as it is: each section at its current Version, older versions already in the lock kept below it, sections the code no longer registers dropped. Run it when a version ships, and the lock guards that shape from then on. Only someone who knows which saves are live can say a change is free, so a lock error is fixed in the code, by bumping Version and adding the Migrate step it names, not by rerunning shot savelock.
A whole example
Data/Types.shot
public type SectionSpec = {
Version : number,
Default : () -> any,
Migrate : ((data : any, fromVersion : number) -> any)?,
}Data/Sections.shot
types script.Parent.Types
local specs : {[string] : SectionSpec} = {}
rootdoc {
Sections holds every save section by name.
}
server module Sections = {
doc {Registers a save section}
function Register(name : string, spec : SectionSpec)
specs[name] = spec
end
}Wallet/Types.shot
doc { The saved "Wallet" section: the coins and gems a player holds }
public type WalletSave = {
Coins : number,
Gems : number,
}Wallet/WalletServer.shot
types script.Parent.Types
use @shot.Data.Sections
local SECTION = "Wallet"
local function defaultSave() : WalletSave
return { Coins = 0, Gems = 0 }
end
rootdoc {
WalletServer keeps each player's coins and gems.
}
server module WalletServer = {
doc {Registers the Wallet section}
function Start()
Sections.Register(SECTION, {
Version = 1,
Default = defaultSave,
})
end
}shot savelock writes Saved.lock:
Saved.lock: the shape each saved section promises to existing saves, per Version. shot savelock writes it, shot check compares the code with it (shot docs SaveLock).
section Wallet version 1 WalletSave
Coins : number
Gems : numberCoins becomes a map of currencies. Change WalletSave to Coins : {[string] : number} and Default to Coins = {}, bump Version to 2 and add the step:
local function migrate(old : any, fromVersion : number) : any
if fromVersion == 1 then
return { Coins = { Gold = old.Coins }, Gems = old.Gems }
end
return old
endwith Migrate = migrate in the Register table. shot check passes, and shot savelock then records version 2 above version 1:
section Wallet version 2 WalletSave
Coins : {[string] : number}
Gems : number
section Wallet version 1 WalletSave
Coins : number
Gems : numberThe errors
A key changed at the same Version, at the key in the record type (renamed, retyped, added or removed):
Wallet/Types.shot:4 | section Wallet (version 1) renames Gems to Jewels (number): a version-1 save loads Jewels as nil and its Gems is never read. Bump Version to 2 and add a Migrate step from 1 that moves Gems to Jewels (if no live save has the old shape, a person reruns shot savelock instead when the new shape ships) |
Wallet/Types.shot:3 | section Wallet (version 1) saves Coins as {[string] : number}, but Saved.lock has number: a saved version-1 Coins still loads as number where the code now expects {[string] : number}. Bump Version to 2 and add a Migrate step from 1 that turns Coins from number into {[string] : number} (if no live save has the old shape, a person reruns shot savelock instead when the new shape ships) |
A bumped Version with no Migrate, or a Migrate with no step from the version in Saved.lock:
Wallet/WalletServer.shot:18 | section Wallet is version 2 but Saved.lock has version 1 and the section has no Migrate: a version-1 save loads unchanged into version-2 code. Add Migrate with a step from 1 that turns Coins from number into {[string] : number}; a person reruns shot savelock when the new shape ships |
Wallet/WalletServer.shot:11 | section Wallet is version 2 but its Migrate has no step from 1 (the version in Saved.lock): a version-1 save loads unchanged into version-2 code. Add if fromVersion == 1 then with a step that turns Coins from number into {[string] : number}; a person reruns shot savelock when the new shape ships |
A Migrate step (if fromVersion == N) that returns a table at once without a key the current shape has: a silent data loss even when everything else passes (WHAT A MIGRATE STEP RETURNS, below):
Wallet/WalletServer.shot:13 section Wallet: the Migrate step from 1 returns no Gems, so a version-1 save loads with Gems nil where version 2 expects number; add Gems = 0 (as Default has it) to that returnA section on one side only, one line each:
Saved.lock:3 Saved.lock has section Wallet, which nothing registers any more: old saves still carry it. Register it again under the name Wallet if it moved (if it is gone on purpose, a person reruns shot savelock to drop it when that ships)
Wallet/WalletServer.shot:18 section Purse is not in Saved.lock, so nothing guards its saves yet: a person records it with shot savelock when the section shipsA step counts as handling version N when Migrate compares with N (== N, <= N or < N+1). A bumped Version whose Migrate handles the locked one is no error; rerun shot savelock when that version ships, so it is guarded too.
What a migrate step returns
What a Migrate step returns is checked by shot check itself: a step that returns a table literal at once must give every key of the current shape that is not optional, and the error names the value Default gives it. A step that builds its result in a local, changes the old table in place or falls through to a later step is not followed: test those with a scenario that loads an old save (see Scenarios).
