Ledger

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
end

table.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)          --> 150

Apply 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}`)
end

Apply 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 datastore

The mock is stricter than Studio on purpose, because Studio hands you request budgets a live server never gets. See Testing.

On this page