Skip to content

Commit

Permalink
Merge pull request #59 from nezuo/collection-read
Browse files Browse the repository at this point in the history
Add Collection:read
  • Loading branch information
nezuo authored Jul 31, 2024
2 parents df9932e + 8e84263 commit 3523158
Show file tree
Hide file tree
Showing 8 changed files with 228 additions and 23 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Lapis Changelog

## Unreleased Changes
* Added `Collection:read` to view a document's data without editing or session locking it. ([#59])

[#59]: https://github.com/nezuo/lapis/pull/59

## 0.3.1 - July 6, 2024
* Added `Document:keyInfo()`. It returns the last updated `DataStoreKeyInfo` returned from loading, saving, or closing the document. ([#50])
Expand Down
35 changes: 35 additions & 0 deletions src/Collection.lua
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,39 @@ function Collection:load(key, defaultUserIds)
end)
end

--[=[
Reads the data of the document with `key` regardless of whether it is session locked. This is useful for viewing a
document without editing or session locking it. The data gets migrated but not saved.
If the document has never been loaded, the promise will return `nil`.
[DataStoreGetOptions.UseCache](https://create.roblox.com/docs/reference/engine/classes/DataStoreGetOptions#UseCache) is disabled.
@param key string
@return Promise<T?>
]=]
function Collection:read(key)
return self.data:read(self.dataStore, key):andThen(function(value, keyInfo)
if value == nil then
return nil
end

local migrationOk, migrated = Migration.migrate(self.options.migrations, value)
if not migrationOk then
return Promise.reject(migrated)
end

if self.options.validate ~= nil then
local validateOk, valid, message = pcall(self.options.validate, migrated)
if not validateOk then
return Promise.reject(`'validate' threw an error: {valid}`)
elseif not valid then
return Promise.reject(`Invalid data: {message}`)
end
end

return value.data, keyInfo
end)
end

return Collection
118 changes: 101 additions & 17 deletions src/Data/Throttle.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ local RunService = game:GetService("RunService")

local Promise = require(script.Parent.Parent.Parent.Promise)

local GET_ASYNC_RETRY_ATTEMPTS = 5
local GET_ASYNC_RETRY_DELAY = 1

local getAsyncOptions = Instance.new("DataStoreGetOptions")
getAsyncOptions.UseCache = false

local function updateAsync(throttle, request)
return Promise.new(function(resolve)
local resultOutside, transformedOutside, keyInfo
Expand Down Expand Up @@ -35,13 +41,28 @@ local function updateAsync(throttle, request)
end)
end

local function getAsync(request)
return Promise.new(function(resolve)
local ok, value, keyInfo = pcall(function()
return request.dataStore:GetAsync(request.key, getAsyncOptions)
end)

if ok then
resolve("succeed", value, keyInfo)
else
resolve("retry", value)
end
end)
end

local Throttle = {}
Throttle.__index = Throttle

function Throttle.new(config)
return setmetatable({
config = config,
queue = {},
updateAsyncQueue = {},
getAsyncQueue = {},
gameClosed = false,
}, Throttle)
end
Expand All @@ -50,20 +71,38 @@ function Throttle:getUpdateAsyncBudget()
return self.config:get("dataStoreService"):GetRequestBudgetForRequestType(Enum.DataStoreRequestType.UpdateAsync)
end

function Throttle:getGetAsyncBudget()
return self.config:get("dataStoreService"):GetRequestBudgetForRequestType(Enum.DataStoreRequestType.GetAsync)
end

function Throttle:start()
RunService.PostSimulation:Connect(function()
for index = #self.queue, 1, -1 do
local request = self.queue[index]
local function retryRequest(request, err)
request.attempts -= 1

if request.attempts == 0 then
request.reject(`DataStoreFailure({err})`)
else
if self.config:get("showRetryWarnings") then
warn(`DataStore operation failed. Retrying...\nError: {err}`)
end

task.wait(request.retryDelay)
end
end

local function updateUpdateAsync()
for index = #self.updateAsyncQueue, 1, -1 do
local request = self.updateAsyncQueue[index]

if request.attempts == 0 then
table.remove(self.queue, index)
table.remove(self.updateAsyncQueue, index)
elseif request.promise == nil and request.cancelOnGameClose and self.gameClosed then
request.resolve("cancelled")
table.remove(self.queue, index)
table.remove(self.updateAsyncQueue, index)
end
end

for _, request in self.queue do
for _, request in self.updateAsyncQueue do
if self:getUpdateAsyncBudget() == 0 then
break
end
Expand All @@ -83,17 +122,44 @@ function Throttle:start()
request.attempts = 0
request.reject(`DataStoreFailure({value})`)
elseif result == "retry" then
request.attempts -= 1
retryRequest(request, value)
else
error("unreachable")
end

request.promise = nil
end)

if promise:getStatus() == Promise.Status.Started then
request.promise = promise
end
end
end

local function updateGetAsync()
for index = #self.getAsyncQueue, 1, -1 do
local request = self.getAsyncQueue[index]

if request.attempts == 0 then
table.remove(self.getAsyncQueue, index)
end
end

if request.attempts == 0 then
request.reject(`DataStoreFailure({value})`)
else
if self.config:get("showRetryWarnings") then
warn(`DataStore operation failed. Retrying...\nError: {value}`)
end
for _, request in self.getAsyncQueue do
if self:getGetAsyncBudget() == 0 then
break
end

task.wait(request.retryDelay)
end
if request.promise ~= nil then
continue
end

local promise = getAsync(request):andThen(function(result, value, keyInfo)
if result == "succeed" then
request.attempts = 0
request.resolve(value, keyInfo)
elseif result == "retry" then
retryRequest(request, value)
else
error("unreachable")
end
Expand All @@ -105,12 +171,17 @@ function Throttle:start()
request.promise = promise
end
end
end

RunService.PostSimulation:Connect(function()
updateUpdateAsync()
updateGetAsync()
end)
end

function Throttle:updateAsync(dataStore, key, transform, cancelOnGameClose, retryAttempts, retryDelay)
return Promise.new(function(resolve, reject)
table.insert(self.queue, {
table.insert(self.updateAsyncQueue, {
dataStore = dataStore,
key = key,
transform = transform,
Expand All @@ -123,4 +194,17 @@ function Throttle:updateAsync(dataStore, key, transform, cancelOnGameClose, retr
end)
end

function Throttle:getAsync(dataStore, key)
return Promise.new(function(resolve, reject)
table.insert(self.getAsyncQueue, {
dataStore = dataStore,
key = key,
attempts = GET_ASYNC_RETRY_ATTEMPTS,
retryDelay = GET_ASYNC_RETRY_DELAY,
resolve = resolve,
reject = reject,
})
end)
end

return Throttle
4 changes: 4 additions & 0 deletions src/Data/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ function Data:waitForOngoingSaves()
return Promise.allSettled(promises)
end

function Data:read(dataStore, key)
return self.throttle:getAsync(dataStore, key)
end

function Data:load(dataStore, key, transform)
return self:waitForOngoingSave(dataStore, key):andThen(function()
local attempts = self.config:get("loadAttempts")
Expand Down
3 changes: 3 additions & 0 deletions src/init.lua
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
--!strict

local Internal = require(script.Internal)
local PromiseTypes = require(script.PromiseTypes)

Expand Down Expand Up @@ -30,6 +32,7 @@ export type CollectionOptions<T> = {

export type Collection<T> = {
load: (self: Collection<T>, key: string, defaultUserIds: { number }?) -> PromiseTypes.TypedPromise<Document<T>>,
read: (self: Collection<T>, key: string) -> PromiseTypes.TypedPromise<T?>,
}

export type Document<T> = {
Expand Down
80 changes: 78 additions & 2 deletions src/init.test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,10 @@ return function(x)
context.write("migration", "migration", "data")

collection:load("migration"):expect()

local readData = collection:read("migration"):expect()

assertEqual(readData, "newData")
end)

x.test("error is thrown if a migration returns nil", function(context)
Expand All @@ -344,6 +348,10 @@ return function(x)
shouldThrow(function()
collection:load("document"):expect()
end, "Migration 1 returned 'nil'")

shouldThrow(function()
collection:read("document"):expect()
end, "Migration 1 returned 'nil'")
end)

x.test("migrations should allow mutable updates", function(context)
Expand Down Expand Up @@ -440,10 +448,12 @@ return function(x)
data = "b",
})

local promise = collection:load("document")
shouldThrow(function()
collection:load("document"):expect()
end, "Saved migration version 2 is not backwards compatible with version 1")

shouldThrow(function()
promise:expect()
collection:read("document"):expect()
end, "Saved migration version 2 is not backwards compatible with version 1")
end
)
Expand Down Expand Up @@ -487,6 +497,10 @@ return function(x)
shouldThrow(function()
collection:load("document"):expect()
end, "Saved migration version 1 is not backwards compatible with version 0")

shouldThrow(function()
collection:read("document"):expect()
end, "Saved migration version 1 is not backwards compatible with version 0")
end)

x.test("migration saves lastCompatibleVersion", function(context)
Expand Down Expand Up @@ -791,4 +805,66 @@ return function(x)
end, "'validate' threw an error", "foo")
end)
end)

x.nested("Collection:read", function()
x.test("returns nil when there is no data", function(context)
local collection = context.lapis.createCollection("collection", {
defaultData = "data",
})

local data, keyInfo = collection:read("key"):expect()

assertEqual(data, nil)
assertEqual(keyInfo, nil)
end)

x.test("returns existing data", function(context)
local collection = context.lapis.createCollection("collection", {
defaultData = "data",
})

collection:load("key", { 321 }):expect()

local data, keyInfo = collection:read("key"):expect()

assertEqual(data, "data")
assertEqual(keyInfo:GetUserIds()[1], 321)
end)

x.test("throws error when data is invalid", function(context)
local collection = context.lapis.createCollection("collection", {
defaultData = "data",
validate = function(data)
return data == "data", "data was invalid"
end,
})

context.write("collection", "key", "INVALID DATA")

shouldThrow(function()
collection:read("key"):expect()
end, "Invalid data")
end)

x.test("throws error when validate throws", function(context)
local created = false
local collection = context.lapis.createCollection("collection", {
defaultData = "data",
validate = function()
if created then
error("validate error")
else
return true
end
end,
})
created = true

context.write("collection", "key", "data")

shouldThrow(function()
collection:read("key"):expect()
end, "'validate' threw an error")
end)
end)
end
6 changes: 3 additions & 3 deletions wally.lock
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ dependencies = []

[[package]]
name = "nezuo/data-store-service-mock"
version = "0.3.5"
version = "0.3.6"
dependencies = []

[[package]]
name = "nezuo/lapis"
version = "0.3.0"
dependencies = [["Promise", "evaera/[email protected]"], ["DataStoreServiceMock", "nezuo/[email protected].5"], ["Midori", "nezuo/[email protected]"]]
version = "0.3.1"
dependencies = [["Promise", "evaera/[email protected]"], ["DataStoreServiceMock", "nezuo/[email protected].6"], ["Midori", "nezuo/[email protected]"]]

[[package]]
name = "nezuo/midori"
Expand Down
2 changes: 1 addition & 1 deletion wally.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ Promise = "evaera/[email protected]"

[dev-dependencies]
Midori = "nezuo/[email protected]"
DataStoreServiceMock = "nezuo/[email protected].5"
DataStoreServiceMock = "nezuo/[email protected].6"

0 comments on commit 3523158

Please sign in to comment.