Getting started
Install Ledger, build a store, load a player.
Install
[server-dependencies]
Ledger = "xoifaii/ledger@4.1.0"It's a server package. Requiring it from a LocalScript won't work, and isn't meant to.
Build a store
A store is one datastore name plus the rules for everything under it. Build it once, near the top of a server script.
Start simple. One field, and a reducer that hands back the next state or nil to refuse:
local Ledger = require(ServerStorage.Ledger)
export type Profile = {
Gold: number
}
local function Reducer(State: Profile, Op: Ledger.Op): Profile?
if Op.Kind == "AddGold" then
if type(Op.Amount) ~= "number" or Op.Amount <= 0 then
return nil
end
return { Gold = State.Gold + Op.Amount }
end
if Op.Kind == "SpendGold" then
if type(Op.Amount) ~= "number" or Op.Amount > State.Gold then
return nil
end
return { Gold = State.Gold - Op.Amount }
end
return nil
end
local Store = Ledger.New({
Name = "PlayerData",
Default = { Gold = 100 },
Reducer = Reducer,
Balance = "Gold",
})The reducer takes the state and one op and gives back the next state, or nil to refuse it. It has
to be pure. Writing a reducer covers the rules and what breaks when you
don't keep them.
Balance is optional. You only need it for transfers, and it names a
number field that's already in Default.
Once there is more than one field
Writing the next state out by hand stops working as soon as Default grows. Any field you don't
mention is not in the state you handed back, so it is gone from that moment on.
Copy the state and change what you need instead:
export type Profile = {
Gold: number,
Items: { string }
}
local function Reducer(State: Profile, Op: Ledger.Op): Profile?
if Op.Kind == "AddGold" then
if type(Op.Amount) ~= "number" or Op.Amount <= 0 then
return nil
end
local Next = table.clone(State)
Next.Gold += Op.Amount
return Next
end
if Op.Kind == "PickUp" then
if type(Op.Item) ~= "string" then
return nil
end
local Next = table.clone(State)
Next.Items = table.clone(State.Items)
table.insert(Next.Items, Op.Item)
return Next
end
return nil
endtable.clone is shallow, so Items needs a copy of its own before you touch it. The state you were
handed is deep frozen, so writing into it throws on the line that did it rather than breaking a fold
somewhere later.
Deeper than that and the copying gets hard to read. Advanced
reducers has a SetPath for it.
Load and unload
Players.PlayerAdded:Connect(function(Player)
Store:Load(Player)
end)
Players.PlayerRemoving:Connect(function(Player)
Store:Unload(Player)
end)
game:BindToClose(function()
Ledger.CloseAll()
end)Load yields until the profile is folded, and kicks the player if it can't read it. Unload saves
whatever is queued and yields until that's durable. CloseAll does the same thing for every store
at once, and it's the only thing you need in BindToClose.
Read and write
local Session = Store:Expect(Player)
print(Session:Get().Gold) --> 100
Session:Apply("AddGold", { Amount = 50 })
print(Session:Get().Gold) --> 150Apply is instant. It runs your reducer against live state, updates it, and queues the op for the
next save. You get back (boolean, Reason?), and false means either your reducer refused it or
Ledger did.
When you need the write to be durable before you act on it, use Commit:
local Ok, Why = Session:Commit("SpendGold", { Amount = 25 }):Wait()
if not Ok then
warn(`could not spend: {Why}`)
endApply and Commit goes into which one to use.
Watch for changes
Session:Observe():Subscribe(function(State)
UpdateGoldLabel(Player, State.Gold)
end)This fires on every change that goes through, including ones that turn up from another server when a transfer or a transaction settles.
Running without a datastore
Ledger.UseMock({ Players = 30 }) -- every store goes in memory, real limits, no API access
Ledger.UseReal() -- back to the real datastoreThe mock is stricter than Studio on purpose, because Studio hands you request budgets a live server never gets. See Testing.