A (untested) community made Reducer by cattalicic:
--!strict
local PARENT = "__parent"
local NAME = "__name"
type GeneratedContext = {
Tree: { string },
Args: { any },
}
local Chain = {}
Chain.prototype = {}
local function assembleTree(sender: any, tree: { string }): { string }
local parent = rawget(sender, PARENT)
local name = rawget(sender, NAME)
if name then
table.insert(tree, 1, name)
end
if parent then
return assembleTree(parent, tree)
end
return tree
end
local function getRoot(sender: any): any
local parent = rawget(sender, PARENT)
if parent then
return getRoot(parent)
end
return sender
end
function Chain.prototype:__index(key: string)
return Chain.new(nil, self, key)
end
function Chain.new(callback: ((GeneratedContext) -> any)?, parent: any?, name: string?)
return setmetatable({
[NAME] = name,
[PARENT] = parent,
__callback = callback,
}, Chain.prototype)
end
function Chain.prototype:__call(...)
local root = getRoot(self)
local callback = rawget(root, "__callback") :: ((GeneratedContext) -> any)?
if not callback then
error("LedgerOps: chain called without a root callback -- did you call Define() correctly?")
end
return callback({ Tree = assembleTree(self, {}), Args = { ... } })
end
function Chain.prototype:__tostring()
return `Op<{table.concat(assembleTree(self, {}), ".")}>`
end
function Chain.prototype:__newindex()
error("LedgerOps: cannot assign into an op namespace")
end
function Chain.prototype:__iter()
error("LedgerOps: cannot iterate an op namespace, call a leaf op instead")
end
export type FieldType = "number" | "string" | "boolean" | "table" | "any"
export type Validator = (value: any) -> boolean
export type FieldSpec = { [string]: FieldType | Validator }
export type Leaf = {
__isLeaf: true,
Fields: FieldSpec,
}
export type SchemaNode = Leaf | { [string]: SchemaNode }
export type Schema = { [string]: SchemaNode }
export type OpDescriptor = {
Kind: string,
Fields: { [string]: any },
}
local LedgerOps = {}
--[=[
Marks a table as a real op kind (a "leaf") rather than a namespace level.
Usage:
LedgerOps.Define({
SpendGold = LedgerOps.Leaf({ Amount = "number" }),
Pets = {
Fuse = LedgerOps.Leaf({ PetIds = "table", PetId = "string" }),
},
})
--]=]
function LedgerOps.Leaf(fields: FieldSpec): Leaf
return { __isLeaf = true, Fields = fields }
end
local function checkType(value: any, spec: FieldType | Validator): (boolean, string?)
if type(spec) == "function" then
-- `type(spec) == "function"` refines `spec` to the Validator arm of the
-- union right here, so no manual cast is needed (see Luau's docs on
-- type refinements: type guards narrow lvalues on the checked branch).
if not spec(value) then
return false, "failed its validator"
end
return true
end
if spec == "any" then
return true
end
if type(value) ~= spec then
return false, `expected {spec}, got {type(value)}`
end
return true
end
local function findLeaf(schema: Schema, tree: { string }): Leaf?
local node: SchemaNode = schema :: any
for _, key in tree do
if type(node) ~= "table" then
return nil
end
local nextNode = (node :: any)[key]
if nextNode == nil then
return nil
end
node = nextNode
end
-- SchemaNode = Leaf | { [string]: SchemaNode } isn't a clean tagged union
-- (per Luau's docs: a tagged union needs a differently-typed discriminant
-- shared across every arm) -- the indexer arm can also resolve .__isLeaf,
-- just typed SchemaNode? instead of the literal `true` Leaf has. So this
-- stays an explicit cast rather than a refined narrow; it's not provably
-- removable without running luau-analyze against it directly.
if type(node) == "table" and (node :: any).__isLeaf then
return node :: any
end
return nil
end
local function resolve(schema: Schema, tree: { string }, args: { any }): OpDescriptor
local path = table.concat(tree, ".")
local leaf = findLeaf(schema, tree)
if not leaf then
error(`LedgerOps: '{path}' is not a defined op kind`)
end
local fields = args[1]
if fields ~= nil and type(fields) ~= "table" then
error(`LedgerOps: '{path}' expects a Fields table, got {type(fields)}`)
end
fields = fields or {}
for fieldName, spec in leaf.Fields do
local ok, why = checkType((fields :: any)[fieldName], spec)
if not ok then
error(`LedgerOps: '{path}' field '{fieldName}' {why}`)
end
end
return { Kind = path, Fields = fields :: any }
end
--[=[
Builds a chainable namespace of op constructors from a schema. Calling a
leaf returns a plain { Kind, Fields } descriptor -- it does not touch
Ledger at all, so these are cheap to build, test, and pass around.
--]=]
function LedgerOps.Define(schema: Schema): any
return Chain.new(function(context: GeneratedContext): OpDescriptor
return resolve(schema, context.Tree, context.Args)
end)
end
--[=[
Same as Define, but calling a leaf commits it immediately against a live
Session or Store instead of just building a descriptor.
local Ops = LedgerOps.Bind(Schema, Session)
Ops.SpendGold({ Amount = 50 }):Wait()
--]=]
function LedgerOps.Bind(schema: Schema, target: { Commit: (any, string, any) -> any }): any
return Chain.new(function(context: GeneratedContext)
local descriptor = resolve(schema, context.Tree, context.Args)
return target:Commit(descriptor.Kind, descriptor.Fields)
end)
end
--[=[
Turns a descriptor into a Tx leg table, filling in the target key.
Store:Tx(`trade:{id}`, {
LedgerOps.LegOf(Ops.SpendGold({ Amount = 500 }), { UserId = Buyer }),
LedgerOps.LegOf(Ops.GiveItem({ Item = "Sword" }), { UserId = Seller }),
})
--]=]
function LedgerOps.LegOf(
descriptor: OpDescriptor,
target: { UserId: number?, Key: string?, Store: any? }
): { UserId: number?, Key: string?, Store: any?, Kind: string, Fields: any }
return {
UserId = target.UserId,
Key = target.Key,
Store = target.Store,
Kind = descriptor.Kind,
Fields = descriptor.Fields,
}
end
return LedgerOps