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 : number

Coins 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
end

with 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 : number

The errors

A key changed at the same Version, at the key in the record type (renamed, retyped, added or removed):

Wallet/Types.shot:4section 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:3section 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:18section 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:11section 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 return

A 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 ships

A 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).