diff --git a/CHANGELOG.md b/CHANGELOG.md index 789768f..87ad6ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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]) diff --git a/src/Collection.lua b/src/Collection.lua index 6c024e3..bc26628 100644 --- a/src/Collection.lua +++ b/src/Collection.lua @@ -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 +]=] +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 diff --git a/src/Data/Throttle.lua b/src/Data/Throttle.lua index 281aa7c..e062b9b 100644 --- a/src/Data/Throttle.lua +++ b/src/Data/Throttle.lua @@ -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 @@ -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 @@ -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 @@ -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 @@ -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, @@ -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 diff --git a/src/Data/init.lua b/src/Data/init.lua index d883c88..55ea2e4 100644 --- a/src/Data/init.lua +++ b/src/Data/init.lua @@ -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") diff --git a/src/init.lua b/src/init.lua index 1d7ffc7..dd01f2d 100644 --- a/src/init.lua +++ b/src/init.lua @@ -1,3 +1,5 @@ +--!strict + local Internal = require(script.Internal) local PromiseTypes = require(script.PromiseTypes) @@ -30,6 +32,7 @@ export type CollectionOptions = { export type Collection = { load: (self: Collection, key: string, defaultUserIds: { number }?) -> PromiseTypes.TypedPromise>, + read: (self: Collection, key: string) -> PromiseTypes.TypedPromise, } export type Document = { diff --git a/src/init.test.lua b/src/init.test.lua index e0a0a50..88074a8 100644 --- a/src/init.test.lua +++ b/src/init.test.lua @@ -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) @@ -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) @@ -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 ) @@ -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) @@ -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 diff --git a/wally.lock b/wally.lock index db8a052..c76e84f 100644 --- a/wally.lock +++ b/wally.lock @@ -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/promise@4.0.0"], ["DataStoreServiceMock", "nezuo/data-store-service-mock@0.3.5"], ["Midori", "nezuo/midori@0.1.4"]] +version = "0.3.1" +dependencies = [["Promise", "evaera/promise@4.0.0"], ["DataStoreServiceMock", "nezuo/data-store-service-mock@0.3.6"], ["Midori", "nezuo/midori@0.1.4"]] [[package]] name = "nezuo/midori" diff --git a/wally.toml b/wally.toml index 292655d..144f952 100644 --- a/wally.toml +++ b/wally.toml @@ -11,4 +11,4 @@ Promise = "evaera/promise@4.0.0" [dev-dependencies] Midori = "nezuo/midori@0.1.4" -DataStoreServiceMock = "nezuo/data-store-service-mock@0.3.5" +DataStoreServiceMock = "nezuo/data-store-service-mock@0.3.6"