Writing a reducer
The one function you own, and the rules it has to keep.
type Reducer<S> = (State: S, Op: Op) -> S?The reducer takes the state and one op, and gives back the next state or nil to refuse. It's the
only place in your game that decides whether a change is allowed.
local function Reducer(State: Profile, Op: Ledger.Op): Profile?
if Op.Kind == "SpendGold" then
if type(Op.Amount) ~= "number" or Op.Amount <= 0 then
return nil
end
if Op.Amount > State.Gold then
return nil
end
local Next = table.clone(State)
Next.Gold -= Op.Amount
return Next
end
return nil
endIt has to be pure
The same (State, Op) has to give the same answer on every server, forever. No os.time(), no
math.random(), no game, no upvalues that move, no reading someone else's data. If you need the
time or a dice roll, work it out at the call site and put it on the op:
Session:Apply("DailyBonus", { At = os.time(), Roll = math.random(1, 6) })A log gets folded on two servers at two different moments. If the reducer reads the clock, those two folds disagree and your data quietly splits in half.
In Studio, Ledger refolds the log after every commit and compares it against live state. If they don't match it warns and names the field that moved. That check doesn't run in production, so fix what it points at rather than shipping around it.
It has to not mutate
Return a new table. table.clone is shallow, so clone each nested table you touch:
local Next = table.clone(State)
Next.Gamepasses = table.clone(State.Gamepasses)
Next.Gamepasses[Pass] = true
return NextThe state you get handed is deep frozen, so a mutation throws on the line that did it instead of corrupting a fold somewhere later.
Cloning by hand stops reading well about three levels down. Advanced
reducers has a SetPath that clones only the path you name.
Freezing can't cover a buffer, because Luau has no way to freeze one. State can hold buffers, and Ledger copies them instead of sharing them, so writing into one can't reach another server or another session. It does still change the state you're holding, and the fold won't agree with itself afterwards. Build a new buffer instead of writing into the one you were given.
A buffer in Default works, and each key gets its own copy. Ledger used to hand every key that had
not stored one yet the same buffer, so one key writing into it changed what all of them read.
Don't freeze what you hand back. Ledger freezes it for you, all the way down. A table you froze yourself carries no promise about the tables inside it, so Ledger has to walk it in full, which costs more than letting Ledger do the freezing.
It has to not yield
No task.wait, no :Wait(), no yielding datastore calls. Ledger runs the reducer inside a guard
that errors if it yields.
It has to return a table or nil
Anything else is a bug. Ledger refuses the op with Invalid and warns.
Leave the reserved fields alone
table.clone(State) carries _Received and _Held across for free, so most reducers never think
about them.
If you build the next state from scratch and drop them, Ledger puts them back, so you can't lose the
applied id set that way. It won't always catch you writing your own values into them. A store with a
Balance overwrites those, but a store without one keeps a hand built _Received, and that breaks
the dedupe.
So don't drop them, and don't write to them. Read them if you want, they're just data.
Ops you don't know
Return nil for anything you don't recognise. Ledger treats that as a refusal, and it makes a
rolling deploy safe. An old server that's never heard of NewFeatureOp refuses it instead of
guessing at it.
That's a kind you don't know yet. A kind you've deleted is the other way round, because nothing is ever going to accept it, and refusing one of those stops the key compacting for good. See changing the reducer.
Check the fields
Ops come from your own code, but they also come back out of a datastore that's been holding them for
weeks, possibly written by a version of your game that doesn't exist anymore. Check the types on the
way in. A reducer that does State.Gold -= Op.Amount without checking Op.Amount is a number will
happily hand you nil gold and take the profile with it.
Coming from Rodux or Redux
The shape is close enough that people reach for it straight away. An op is an action, Op.Kind is
action.type, and a handler table keyed by kind reads a lot nicer than a long if chain.
That part is fine. Two of the conventions are backwards here though, and both of them cost you money rather than throwing.
nil means refused, not unhandled
In Redux the default case returns the state unchanged, and returning nothing is an error. Here it's
the other way round. Ledger reads any table you return as accepted, including the exact state you
were handed. Returning nil is the only way to refuse.
So a Redux style default case accepts every op you never wrote a handler for:
-- wrong here, right in Redux
local function Reducer(State, Op)
local Handler = Handlers[Op.Kind]
if Handler == nil then
return State -- Ledger reads this as "yes, applied"
end
return Handler(State, Op)
endThat isn't a harmless no-op. An op carrying a Once name gets that name
written into the applied set the moment your reducer hands back a table, so the receipt is marked
granted while nothing was granted. DidApply says true, the retry never comes, and the player is
out the Robux. A transaction leg does the same thing and commits on a key that did nothing.
The fix is one word:
local function Reducer(State: Profile, Op: Ledger.Op): Profile?
local Handler = Handlers[Op.Kind]
if Handler == nil then
return nil -- refused
end
return Handler(State, Op)
endEvery handler in the table returns nil to refuse too, same as it would inline.
combineReducers can't refuse
combineReducers builds a fresh table out of every slice on every action. It therefore always
returns a table, which here means it always accepts. A slice that wanted to refuse has no way to say
so, because the combined result is a table either way.
It's worse than losing the refusal. In Lua, a slice returning nil assigns nil into the combined
table, which deletes that key. So the refusal doesn't just get swallowed, it takes the field with
it.
Route by kind instead and let nil come straight back up:
local Slices = {
SpendGold = function(State, Op)
if Op.Amount > State.Gold then
return nil
end
local Next = table.clone(State)
Next.Gold -= Op.Amount
return Next
end,
UnlockGamepass = function(State, Op)
if State.Gamepasses[Op.Pass] then
return nil
end
local Next = table.clone(State)
Next.Gamepasses = table.clone(State.Gamepasses)
Next.Gamepasses[Op.Pass] = true
return Next
end,
}
local function Reducer(State: Profile, Op: Ledger.Op): Profile?
local Slice = Slices[Op.Kind]
return if Slice then Slice(State, Op) else nil
endEach handler still owns one field, which is what you wanted combineReducers for. It just returns
the whole state rather than a slice of it, so a refusal is still a refusal.
The rest of Rodux doesn't come with it
Don't build a Rodux.Store. Ledger is the store, state lives in the log, and Session:Observe() is
your subscribe. Two stores holding the same data is how they drift apart.
No middleware and no thunks. The reducer can't yield, so anything async happens before you call
Apply or Commit, and the result rides in on the op.
No Immer style drafts either. State is deep frozen, so mutating it throws on the line that did it. Clone what you touch.
One convention does carry over cleanly. Redux asks you to keep actions serializable by convention.
Ledger enforces it, because ops go in a datastore, and an op that isn't JSON is refused with
Invalid instead of failing at save time.
Balance and transactions
If the store names a Balance field, Ledger wraps your reducer with the transfer ops
(__TransferReserve, __TransferDeliver and the rest) before it ever reaches you. You never handle
those kinds and you'll never see them in your if chain. Same goes for transaction bookkeeping and
Store:Reset.