Ledger
Guides

Entity stores

String keys for clans, listings and anything else no single server owns.

A store with Keys = "String" isn't about players. The keys are names you pick, and the data underneath belongs to nobody in particular.

local MAX_MEMBERS = 50

local Clans = Ledger.New({
	Name = "Clans",
	Keys = "String",
	Default = { Members = {}, Count = 0, Treasury = 0, Level = 1 },
	Balance = "Treasury",
	Reducer = function(State, Op)
		if Op.Kind == "Join" then
			local Who = tostring(Op.UserId)
			if State.Members[Who] then
				return nil
			end
			if State.Count >= MAX_MEMBERS then
				return nil
			end

			local Next = table.clone(State)
			Next.Members = table.clone(State.Members)
			Next.Members[Who] = true
			Next.Count += 1
			return Next
		end

		return nil
	end,
})

Two lines in there are easy to get wrong.

tostring(Op.UserId), not Op.UserId. A table with only number keys is an array. A UserId is a huge number, so Members becomes an array with millions of gaps. A datastore cannot store that. Ledger checks each op on its own, and each op passes, so the writes keep working. The key then fails to compact and the log grows forever. String keys make Members a dictionary.

State.Count, not #State.Members. # gives 0 on a dictionary, so the cap never applies. The clan above fills to sixty.

This is where not having a lock matters most. Twenty servers can be writing to the same clan at the same time, and the fold decides the membership cap the same way on all of them. There's no owner server, no lease, and nothing to recover if a server dies mid write.

Working with them

There are no sessions, because nobody is logged in to a clan. You write with Edit and read with Peek:

local Ok, Why = Clans:Edit("cool-guys", "Join", { UserId = Player.UserId }):Wait()
if not Ok and Why == Ledger.Reason.Refused then
	Tell(Player, "that clan is full")
end

local State, Why = Clans:Peek("cool-guys"):Wait()
if State == nil then
	warn(`could not read that clan: {Why}`)
	return
end
print(State.Level)

A clan nobody has created yet folds to your Default, so nil there always means the read itself failed rather than the clan being missing.

Every method that takes a Key works. Transfer, Tx, DidApply, History, PeekVersion, Reset and Erase all behave the same way they do on a player store.

The session methods don't. Load, Unload, Get, Expect, IsLoaded, WaitForLoaded and Read all throw on a string keyed store, because there's no player to hang a session on.

Keys

A key is a string of 1 to 50 characters and it has to be valid UTF-8. Roblox sets that limit, so Ledger can't raise it.

Pick keys that come from something stable. A clan id, a listing id, a slug. Don't build them out of anything that might change, because there's no rename.

Caching

Peek reads the record every time you call it. There's no cache, because with twenty servers writing to the key a cached copy would be out of date almost immediately. It does mean you shouldn't call it in a loop or per frame.

Read it once, hold onto it, and read again after you write.

Mixing them

A transaction can touch a player store and an entity store in the same commit, which is the usual reason to have both:

Players:Tx(`donate:{OrderId}`, {
	{ UserId = Player.UserId, Kind = "SpendGold", Fields = { Amount = 500 } },
	{ Store = Clans, Key = "cool-guys", Kind = "Donate", Fields = { Amount = 500 } },
}):Wait()

Both sides move or neither does. See Transactions.

On this page