This commit is contained in:
lx
2026-07-06 14:01:11 -04:00
commit c4f97d729d
16468 changed files with 935321 additions and 0 deletions
@@ -0,0 +1,245 @@
local CorePackages = game:GetService("CorePackages")
local Result = require(CorePackages.AppTempCommon.LuaApp.Result)
local Promise = require(CorePackages.AppTempCommon.LuaApp.Promise)
local PromiseUtilities = require(CorePackages.AppTempCommon.LuaApp.PromiseUtilities)
local RetrievalStatus = require(CorePackages.AppTempCommon.LuaApp.Enum.RetrievalStatus)
local UpdateFetchingStatus = require(CorePackages.AppTempCommon.LuaApp.Actions.UpdateFetchingStatus)
--[[
PerformFetch wraps the notion of a network request together with its fetching status
so that it is easier to de-duplicate concurrent requests for the same resource. The
fetching status for individual fetching operations are available in the store as:
storeState.FetchingStatus[key]
When you use one of the methods in this helper, you provide a key (or keymap), and
supply a functor that will only be called when a fetch actually needs to be performed.
Any follow-up andThen/catch clauses will be correctly daisy-chained onto the original
ongoing fetch request if one is already underway.
]]
local PerformFetch = {}
local batchPromises = {} -- fetch key = outstanding promise from PerformFetch.Batch
--[[
Helper function for unit tests to be able to clean up batchPromises created from
previous test case. This is because unit tests don't wait until the mock requests
are resolved and moves onto the next test. If tests happen to generate duplicate
fetchStatusKey, unresolved batchPromise will throw thinking that the promise does
not have the correct status.
]]
function PerformFetch.ClearOutstandingPromiseStatus()
batchPromises = {}
end
local function singleFetchKeymapper(item)
-- Single fetch keys are used directly
return item
end
--[[
Get the fetching status for a given status key. Defaults to
RetrievalStatus.NotStarted for missing keys.
]]
function PerformFetch.GetStatus(state, fetchStatusKey)
assert(typeof(state) == "table")
assert(typeof(fetchStatusKey) == "string")
assert(#fetchStatusKey > 0)
return state.FetchingStatus[fetchStatusKey] or RetrievalStatus.NotStarted
end
--[[
Perform a fetch operation for a single resource.
Args:
fetchStatusKey - String key for the fetching status to index the Rodux store.
fetchFunctor - Functor to call when a fetch needs to be performed for fetchStatusKey.
Returns:
A Promise that resolves or rejects in accordance with the result of fetchFunctor, or the
promise for the original fetch if one is already ongoing.
Usage:
In your main thunk, wrap your inner store function with this thunk, like this:
return function(arg1, arg2)
return PerformFetch.Single("mykey", function(store)
return doYourLogicHere() -- Must return a Promise!!!
end)
end
Please note that in order for single fetches to integrate well with batch fetches,
your promise must NEVER resolve or reject with multiple arguments! Wrap your results
in a table instead.
]]
function PerformFetch.Single(fetchStatusKey, fetchFunctor)
assert(typeof(fetchStatusKey) == "string")
assert(typeof(fetchFunctor) == "function")
assert(#fetchStatusKey > 0)
return function(store)
-- Call batch API to handle the individual fetch
return PerformFetch.Batch({ fetchStatusKey }, singleFetchKeymapper, function(batchStore, itemsToFetch)
assert(#itemsToFetch == 1)
local functorPromise = fetchFunctor(batchStore)
assert(Promise.is(functorPromise))
return functorPromise:andThen(function(...)
assert(#{...} <= 1)
return Promise.resolve({ [fetchStatusKey] = Result.new(true, (...)) })
end, function(...)
assert(#{...} <= 1)
return Promise.resolve({ [fetchStatusKey] = Result.new(false, (...)) })
end)
end)(store):andThen(function(batchResults)
local success, value = batchResults[fetchStatusKey]:unwrap()
if success then
return Promise.resolve(value)
else
return Promise.reject(value)
end
end)
end
end
--[[
Perform a fetch operation for multiple resources at once (batching).
Args:
items - The list of item ids that need to be fetched.
keyMapper - A function that maps items to string keys for the Rodux store.
fetchFunctor - A function that will be called when at least one item needs to be fetched.
Returns:
A Promise that always resolves. Result data is returned in a single table to the
andThen() clause, where item fetch keys are the keys and the results for each key
are encoded using a Result object.
Usage:
In your main thunk, wrap your inner store function with this thunk, like this:
local MAPPER = function(item)
return doSomething(item) -- Each key must be unique
end
return function(arg1, arg2)
local allItems = makeYourItemsList()
return PerformFetch.Batch(allItems, MAPPER, function(store, itemsToFetch)
return doYourLogicHere(itemsToFetch) -- Must return a Promise!!!
end)
end
Your implementation of fetchFunctor should return a promise that resolves
according to the structure of PromiseUtilities.Batch, ex:
return Promise.resolve({
itemFetchKey1 = Result.new(true, payload1),
itemFetchKey2 = Result.new(false, payload2), -- failed
})
Any other resolving arguments will be dropped for consistency and safety of the API.
Since this is a batching API, your implementation should NOT reject().
Please keep in mind that batching calls have to fit into an environment where they may
be daisy chained onto other batching calls, and those results have to be amalgamated
at the end of the chain into unique tables for each of the callers!
]]
function PerformFetch.Batch(items, keyMapper, fetchFunctor)
assert(typeof(items) == "table")
assert(typeof(keyMapper) == "function")
assert(typeof(fetchFunctor) == "function")
return function(store)
local itemsToFetch = {}
local itemsToFetchKeyMap = {}
local batchPromisesForItemsAlreadyBeingFetched = {}
-- Filter out items that do not need to be fetched
for _, item in ipairs(items) do
local fetchStatusKey = keyMapper(item)
local fetchingStatus = PerformFetch.GetStatus(store:getState(), fetchStatusKey)
local batchPromise = batchPromises[fetchStatusKey]
if batchPromise then
assert(fetchingStatus == RetrievalStatus.Fetching)
batchPromisesForItemsAlreadyBeingFetched[fetchStatusKey] = batchPromise
else
assert(fetchingStatus ~= RetrievalStatus.Fetching)
table.insert(itemsToFetch, item)
itemsToFetchKeyMap[item] = fetchStatusKey
end
end
local doResolve
local batchFetchingPromise = Promise.new(function(resolve)
doResolve = resolve
end)
-- Call functor if there are items to fetch, otherwise short-circuit it
-- We want to call it FIRST because we need to kick off async fetch before blocking
-- on other responses.
local functorPromise
if #itemsToFetch > 0 then
-- Place remaining items into fetching state and make entry in table before
-- we kick off functor just in case it returns already-completed promise
for _, fetchStatusKey in pairs(itemsToFetchKeyMap) do
store:dispatch(UpdateFetchingStatus(fetchStatusKey, RetrievalStatus.Fetching))
batchPromises[fetchStatusKey] = batchFetchingPromise
end
functorPromise = fetchFunctor(store, itemsToFetch)
assert(Promise.is(functorPromise))
else
functorPromise = Promise.resolve({})
end
functorPromise:andThen(function(myResults)
myResults = myResults or {} -- No resolve args = empty table for ease of use
return PromiseUtilities.Batch(batchPromisesForItemsAlreadyBeingFetched):andThen(function(batchResults)
local filteredResults = {}
for batchKey, batchResult in pairs(batchResults) do
-- Extract only the result for the key we care about from the batch results
local _, value = batchResult:unwrap()
filteredResults[batchKey] = value[batchKey]
end
return myResults, filteredResults
end)
end,
function()
assert(false, "PerformFetch fetchFunctor should never reject")
end):andThen(function(myResults, batchResults)
-- Iterate on requested items rather than on actual result set
-- so that we are sure to check all our keys and ignore extra ones
for _, fetchKey in pairs(itemsToFetchKeyMap) do
local resultObj = myResults[fetchKey]
if Result.is(resultObj) then
batchResults[fetchKey] = resultObj
else
batchResults[fetchKey] = Result.error()
end
-- Update fetching status in store from Result object status
-- (The extra parens unwrap a multi-return value!)
local itemStatus = (batchResults[fetchKey]:unwrap()) and RetrievalStatus.Done or RetrievalStatus.Failed
store:dispatch(UpdateFetchingStatus(fetchKey, itemStatus))
batchPromises[fetchKey] = nil
end
return batchResults
end):andThen(function(joinedResults)
doResolve(joinedResults)
end)
return batchFetchingPromise
end
end
return PerformFetch
@@ -0,0 +1,510 @@
return function()
local PerformFetch = require(script.Parent.PerformFetch)
local CorePackages = game:GetService("CorePackages")
local Rodux = require(CorePackages.Rodux)
local FetchingStatus = require(CorePackages.AppTempCommon.LuaApp.Reducers.FetchingStatus)
local RetrievalStatus = require(CorePackages.AppTempCommon.LuaApp.Enum.RetrievalStatus)
local Promise = require(CorePackages.AppTempCommon.LuaApp.Promise)
local Result = require(CorePackages.AppTempCommon.LuaApp.Result)
local function batchKeyMapper(item)
return tostring(item) .. "_key"
end
local TEST_ITEM_1 = "item1"
local TEST_ITEM_2 = "item2"
local TEST_KEY_1 = batchKeyMapper(TEST_ITEM_1)
local TEST_KEY_2 = batchKeyMapper(TEST_ITEM_2)
local function MockReducer(state, action)
state = state or {}
return {
FetchingStatus = FetchingStatus(state.FetchingStatus, action),
}
end
local function makeResolver()
local startResolve
local startReject
local testPromise = Promise.new(function(resolve, reject)
startResolve = resolve
startReject = reject
end)
return {
promise = testPromise,
resolve = startResolve,
reject = startReject
}
end
local function doDispatchSingle(store, key, functor)
-- Wrap functor like a thunk would normally be
local thunkFunc = function()
return PerformFetch.Single(key, functor)
end
return store:dispatch(thunkFunc())
end
local function doDispatchBatch(store, items, functor)
-- Wrap functor like a thunk would normally be
local thunkFunc = function()
return PerformFetch.Batch(items, batchKeyMapper, functor)
end
return store:dispatch(thunkFunc())
end
local function doBasicSingleTest(key)
local resolver = makeResolver()
local store = Rodux.Store.new(MockReducer, { }, { Rodux.thunkMiddleware })
local thunkPromise = doDispatchSingle(store, key, function()
return resolver.promise
end)
return {
store = store,
resolver = resolver,
promise = thunkPromise
}
end
local function doBasicBatchTest(keys)
local resolver = makeResolver()
local store = Rodux.Store.new(MockReducer, { }, { Rodux.thunkMiddleware })
local thunkPromise = doDispatchBatch(store, keys, function()
return resolver.promise
end)
return {
store = store,
resolver = resolver,
promise = thunkPromise
}
end
describe("PerformFetch.GetStatus", function()
it("should return NotStarted for missing key", function()
local state = { FetchingStatus = {} }
local status = PerformFetch.GetStatus(state, TEST_KEY_1)
expect(status).to.equal(RetrievalStatus.NotStarted)
end)
it("should return matching status for state in store", function()
local statusesToTest = {
RetrievalStatus.NotStarted,
RetrievalStatus.Fetching,
RetrievalStatus.Done,
RetrievalStatus.Failed
}
for _, testStatus in ipairs(statusesToTest) do
local state = {
FetchingStatus = {
[TEST_KEY_1] = testStatus
}
}
expect(PerformFetch.GetStatus(state, TEST_KEY_1)).to.equal(testStatus)
end
expect(#statusesToTest).to.equal(4)
end)
end)
describe("PerformFetch.Single", function()
it("should set fetching state in store when fetch begins", function()
local bundle = doBasicSingleTest(TEST_KEY_1)
expect(bundle.store:getState().FetchingStatus[TEST_KEY_1]).to.equal(RetrievalStatus.Fetching)
bundle.resolver.resolve() -- clear key from global fetchingPromiseMap or later tests get blocked
end)
it("should pass store parameter to fetch functor", function()
local originalStore = Rodux.Store.new(MockReducer, { }, { Rodux.thunkMiddleware })
local newStore
doDispatchSingle(originalStore, TEST_KEY_1, function(store)
newStore = store
return Promise.resolve()
end)
expect(newStore ~= nil).to.equal(true)
end)
it("should set fetching state to done for sync resolve", function()
local store = Rodux.Store.new(MockReducer, { }, { Rodux.thunkMiddleware })
doDispatchSingle(store, TEST_KEY_1, function()
return Promise.resolve()
end)
expect(store:getState().FetchingStatus[TEST_KEY_1]).to.equal(RetrievalStatus.Done)
end)
it("should set fetching state to failed for sync reject", function()
local store = Rodux.Store.new(MockReducer, { }, { Rodux.thunkMiddleware })
doDispatchSingle(store, TEST_KEY_1, function()
return Promise.reject()
end)
expect(store:getState().FetchingStatus[TEST_KEY_1]).to.equal(RetrievalStatus.Failed)
end)
it("should set fetching state to Done after async fetch resolves", function()
local bundle = doBasicSingleTest(TEST_KEY_1)
bundle.resolver.resolve()
expect(bundle.store:getState().FetchingStatus[TEST_KEY_1]).to.equal(RetrievalStatus.Done)
end)
it("should set fetching state to Failed after async fetch rejects", function()
local bundle = doBasicSingleTest(TEST_KEY_1)
bundle.resolver.reject()
expect(bundle.store:getState().FetchingStatus[TEST_KEY_1]).to.equal(RetrievalStatus.Failed)
end)
it("should not mix fetching status of two separate keys", function()
local store = Rodux.Store.new(MockReducer, { }, { Rodux.thunkMiddleware })
doDispatchSingle(store, TEST_KEY_1, function()
return Promise.resolve()
end)
doDispatchSingle(store, TEST_KEY_2, function()
return Promise.reject()
end)
expect(store:getState().FetchingStatus[TEST_KEY_1]).to.equal(RetrievalStatus.Done)
expect(store:getState().FetchingStatus[TEST_KEY_2]).to.equal(RetrievalStatus.Failed)
end)
it("should pass original promise args to daisy-chained promise upon resolve", function()
local store = Rodux.Store.new(MockReducer, { }, { Rodux.thunkMiddleware })
local testTable = { a = 1 }
local passedArg
doDispatchSingle(store, TEST_KEY_1, function()
return Promise.resolve(testTable)
end):andThen(function(results)
passedArg = results
end)
expect(passedArg).to.equal(testTable)
end)
it("should pass original promise args to daisy-chained promise upon reject", function()
local store = Rodux.Store.new(MockReducer, { }, { Rodux.thunkMiddleware })
local testTable = { a = 1 }
local passedArg
doDispatchSingle(store, TEST_KEY_1, function()
return Promise.reject(testTable)
end):catch(function(results)
passedArg = results
end)
expect(passedArg).to.equal(testTable)
end)
it("should not call second thunk instance for same key while request is ongoing", function()
local store = Rodux.Store.new(MockReducer, { }, { Rodux.thunkMiddleware })
local firstThunkExecuted = false
local secondThunkExecuted = false
local firstThunkResolver = makeResolver()
doDispatchSingle(store, TEST_KEY_1, function()
firstThunkExecuted = true
return firstThunkResolver.promise
end)
doDispatchSingle(store, TEST_KEY_1, function()
secondThunkExecuted = true
return Promise.resolve()
end)
expect(store:getState().FetchingStatus[TEST_KEY_1]).to.equal(RetrievalStatus.Fetching)
expect(firstThunkExecuted).to.equal(true)
expect(secondThunkExecuted).to.equal(false)
firstThunkResolver.resolve()
end)
it("should call both thunks when the first one is completed soon enough", function()
local store = Rodux.Store.new(MockReducer, { }, { Rodux.thunkMiddleware })
local firstThunkExecuted = false
local secondThunkExecuted = false
doDispatchSingle(store, TEST_KEY_1, function()
firstThunkExecuted = true
return Promise.resolve()
end)
doDispatchSingle(store, TEST_KEY_1, function()
secondThunkExecuted = true
return Promise.resolve()
end)
expect(firstThunkExecuted).to.equal(true)
expect(secondThunkExecuted).to.equal(true)
end)
it("should resolve daisy-chained promises after thunk resolves", function()
local store = Rodux.Store.new(MockReducer, { }, { Rodux.thunkMiddleware })
local chainedPromiseExecuted = false
doDispatchSingle(store, TEST_KEY_1, function()
return Promise.resolve()
end):andThen(function()
chainedPromiseExecuted = true
end):catch(function()
assert(false)
end)
expect(chainedPromiseExecuted).to.equal(true)
end)
it("should reject daisy-chained promises after thunk rejects", function()
local store = Rodux.Store.new(MockReducer, { }, { Rodux.thunkMiddleware })
local chainedCatchExecuted = false
doDispatchSingle(store, TEST_KEY_1, function()
return Promise.reject()
end):andThen(function()
assert(false)
end):catch(function()
chainedCatchExecuted = true
end)
expect(chainedCatchExecuted).to.equal(true)
end)
it("should resolve daisy-chained promises on second thunk after first resolves", function()
local store = Rodux.Store.new(MockReducer, { }, { Rodux.thunkMiddleware })
local secondPromiseResolved = false
local startResolve
local firstThunkPromise = Promise.new(function(resolve)
startResolve = resolve
end)
doDispatchSingle(store, TEST_KEY_1, function()
return firstThunkPromise
end)
doDispatchSingle(store, TEST_KEY_1, function()
assert(false)
return Promise.reject()
end):andThen(function()
secondPromiseResolved = true
end):catch(function()
assert(false)
end)
expect(secondPromiseResolved).to.equal(false)
startResolve()
expect(secondPromiseResolved).to.equal(true)
end)
it("should reject daisy-chained promises on second thunk after first thunk rejects", function()
local store = Rodux.Store.new(MockReducer, { }, { Rodux.thunkMiddleware })
local secondPromiseRejected = false
local startReject
local firstThunkPromise = Promise.new(function(_, reject)
startReject = reject
end)
doDispatchSingle(store, TEST_KEY_1, function()
return firstThunkPromise
end)
doDispatchSingle(store, TEST_KEY_1, function()
return Promise.new(function() end)
end):andThen(function()
assert(false)
end):catch(function()
secondPromiseRejected = true
end)
expect(secondPromiseRejected).to.equal(false)
startReject()
expect(secondPromiseRejected).to.equal(true)
end)
end)
describe("PerformFetch.Batch", function()
it("should set fetching state in store for all batch items when fetching begins", function()
local originalItemList = { TEST_ITEM_1, TEST_ITEM_2 }
local bundle = doBasicBatchTest(originalItemList)
expect(bundle.store:getState().FetchingStatus[TEST_KEY_1]).to.equal(RetrievalStatus.Fetching)
expect(bundle.store:getState().FetchingStatus[TEST_KEY_2]).to.equal(RetrievalStatus.Fetching)
local results = {
[TEST_KEY_1] = Result.new(true),
[TEST_KEY_2] = Result.new(true),
}
bundle.resolver.resolve(results) -- Cleanup to avoid test blockage
end)
it("should set fetching state to matching status for all batch items when fetching completes successfully", function()
local originalItemList = { TEST_ITEM_1, TEST_ITEM_2 }
local bundle = doBasicBatchTest(originalItemList)
local results = {
[TEST_KEY_1] = Result.new(true),
[TEST_KEY_2] = Result.new(false),
}
bundle.resolver.resolve(results)
expect(bundle.store:getState().FetchingStatus[TEST_KEY_1]).to.equal(RetrievalStatus.Done)
expect(bundle.store:getState().FetchingStatus[TEST_KEY_2]).to.equal(RetrievalStatus.Failed)
end)
it("should fail items when they are not in the result list", function()
local originalItemList = { TEST_ITEM_1, TEST_ITEM_2 }
local bundle = doBasicBatchTest(originalItemList)
bundle.resolver.resolve({})
expect(bundle.store:getState().FetchingStatus[TEST_KEY_1]).to.equal(RetrievalStatus.Failed)
expect(bundle.store:getState().FetchingStatus[TEST_KEY_2]).to.equal(RetrievalStatus.Failed)
end)
it("should return a daisy chainable batch style promise that resolves with results", function()
local originalItemList = { TEST_ITEM_1, TEST_ITEM_2 }
local bundle = doBasicBatchTest(originalItemList)
local chainedResults = nil
bundle.promise:andThen(function(promisedResults)
chainedResults = promisedResults
end)
local results = {
[TEST_KEY_1] = Result.new(true, 42),
[TEST_KEY_2] = Result.new(false, 29),
}
bundle.resolver.resolve(results)
local result1Status, result1Value = chainedResults[TEST_KEY_1]:unwrap()
local result2Status, result2Value = chainedResults[TEST_KEY_2]:unwrap()
expect(result1Status).to.equal(true)
expect(result1Value).to.equal(42)
expect(result2Status).to.equal(false)
expect(result2Value).to.equal(29)
end)
it("should not call batch functor when there are no items to fetch", function()
local store = Rodux.Store.new(MockReducer, { }, { Rodux.thunkMiddleware })
local promise = doDispatchBatch(store, {}, function()
assert(false, "Functor should not be called when there are no items to fetch")
end)
local promiseResolved = false
promise:andThen(function()
promiseResolved = true
end)
expect(promiseResolved).to.equal(true)
end)
it("should amalgamate results from multiple batch calls", function()
local testItemList = { TEST_ITEM_1, TEST_ITEM_2 }
local bundle = doBasicBatchTest(testItemList)
local promise2 = doDispatchBatch(bundle.store, testItemList, function(_, itemsToFetch)
assert(false, "second batch should not be called")
return Promise.resolve({ })
end)
local chainedResults = nil
promise2:andThen(function(promisedResults)
chainedResults = promisedResults
end)
local results = {
[TEST_KEY_1] = Result.new(true, 41),
[TEST_KEY_2] = Result.new(false, 39),
}
bundle.resolver.resolve(results)
local result1Status, result1Value = chainedResults[TEST_KEY_1]:unwrap()
local result2Status, result2Value = chainedResults[TEST_KEY_2]:unwrap()
expect(result1Status).to.equal(true)
expect(result1Value).to.equal(41)
expect(result2Status).to.equal(false)
expect(result2Value).to.equal(39)
end)
end)
describe("PerformFetch with mixed Single/Batch", function()
it("should include outstanding single results for matching batch keys", function()
local singleBundle = doBasicSingleTest(TEST_KEY_1)
local batchItemCount = -1
local batchPromise = doDispatchBatch(singleBundle.store, { TEST_ITEM_1, TEST_ITEM_2 },
function(_, items)
batchItemCount = #items
return Promise.resolve({ [TEST_KEY_2] = Result.new(false, 35) })
end)
singleBundle.resolver.resolve(49)
local chainedResults = nil
batchPromise:andThen(function(results)
chainedResults = results
end)
local result1Status, result1Value = chainedResults[TEST_KEY_1]:unwrap()
local result2Status, result2Value = chainedResults[TEST_KEY_2]:unwrap()
expect(result1Status).to.equal(true)
expect(result1Value).to.equal(49)
expect(result2Status).to.equal(false)
expect(result2Value).to.equal(35)
expect(batchItemCount).to.equal(1)
end)
it("should use batch result for duplicate single request", function()
local batchBundle = doBasicBatchTest({ TEST_ITEM_1, TEST_ITEM_2 })
local singlePromise = doDispatchSingle(batchBundle.store, TEST_KEY_1, function()
assert(false, "Single functor should not be called")
return Promise.reject()
end)
batchBundle.resolver.resolve({
[TEST_KEY_1] = Result.new(true, 42),
[TEST_KEY_2] = Result.new(true, 39)
})
local chainedResult = nil
singlePromise:andThen(function(result)
chainedResult = result
end)
expect(chainedResult).to.equal(42)
end)
end)
end