@quenty/datastore 13.40.0 → 13.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/package.json +9 -9
- package/src/Server/DataStore.HookErrors.spec.lua +92 -0
- package/src/Server/DataStore.LoadErrors.spec.lua +133 -0
- package/src/Server/DataStore.Reentrance.spec.lua +119 -0
- package/src/Server/DataStore.SessionLock.spec.lua +478 -0
- package/src/Server/DataStore.SessionMessaging.spec.lua +71 -0
- package/src/Server/DataStore.TwoServerLock.spec.lua +190 -0
- package/src/Server/DataStore.lua +89 -18
- package/src/Server/DataStore.spec.lua +237 -0
- package/src/Server/DataStoreLockHelper.spec.lua +239 -0
- package/src/Server/DataStoreMessageHelper.spec.lua +134 -0
- package/src/Server/DataStoreNonRetryableLoadError.lua +61 -0
- package/src/Server/DataStoreTestUtils.lua +206 -0
- package/src/Server/GameDataStoreService.lua +26 -0
- package/src/Server/GameDataStoreService.spec.lua +215 -0
- package/src/Server/Mocks/DataStoreMock.lua +405 -0
- package/src/Server/Mocks/DataStoreMock.spec.lua +384 -0
- package/src/Server/Modules/DataStoreSnapshotUtils.spec.lua +91 -0
- package/src/Server/Modules/DataStoreStage.lua +8 -2
- package/src/Server/Modules/DataStoreStage.spec.lua +539 -0
- package/src/Server/Modules/DataStoreWriter.spec.lua +399 -0
- package/src/Server/PlayerDataStoreManager.RemovalCallbacks.spec.lua +99 -0
- package/src/Server/PlayerDataStoreManager.lua +36 -4
- package/src/Server/PlayerDataStoreManager.spec.lua +202 -0
- package/src/Server/PlayerDataStoreService.lua +18 -0
- package/src/Server/PlayerDataStoreService.spec.lua +218 -0
- package/src/Server/PrivateServerDataStoreService.lua +23 -1
- package/src/Server/PrivateServerDataStoreService.spec.lua +242 -0
- package/src/Server/Utility/DataStorePromises.lua +18 -5
- package/src/Server/Utility/DataStorePromises.spec.lua +363 -0
- package/src/jest.config.lua +3 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
--!nonstrict
|
|
2
|
+
--[[
|
|
3
|
+
Integration coverage for GameDataStoreService wired through a real ServiceBag, with the underlying
|
|
4
|
+
datastore injected via the SetRobloxDataStore test seam. It is not session-locking, so a failing
|
|
5
|
+
load rejects promptly rather than hanging.
|
|
6
|
+
|
|
7
|
+
@class GameDataStoreService.spec.lua
|
|
8
|
+
]]
|
|
9
|
+
local require = require(script.Parent.loader).load(script)
|
|
10
|
+
|
|
11
|
+
local DataStoreMock = require("DataStoreMock")
|
|
12
|
+
local Jest = require("Jest")
|
|
13
|
+
local Maid = require("Maid")
|
|
14
|
+
local PromiseTestUtils = require("PromiseTestUtils")
|
|
15
|
+
local ServiceBag = require("ServiceBag")
|
|
16
|
+
|
|
17
|
+
local describe = Jest.Globals.describe
|
|
18
|
+
local expect = Jest.Globals.expect
|
|
19
|
+
local it = Jest.Globals.it
|
|
20
|
+
|
|
21
|
+
local function setup(mock)
|
|
22
|
+
local maid = Maid.new()
|
|
23
|
+
mock = mock or DataStoreMock.new()
|
|
24
|
+
|
|
25
|
+
local serviceBag = maid:Add(ServiceBag.new())
|
|
26
|
+
local service = serviceBag:GetService(require("GameDataStoreService"))
|
|
27
|
+
serviceBag:Init()
|
|
28
|
+
service:SetRobloxDataStore(mock)
|
|
29
|
+
serviceBag:Start()
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
service = service,
|
|
33
|
+
mock = mock,
|
|
34
|
+
destroy = function()
|
|
35
|
+
maid:DoCleaning()
|
|
36
|
+
end,
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
describe("GameDataStoreService.PromiseDataStore", function()
|
|
41
|
+
it("should resolve a datastore that loads successfully against a healthy mock", function()
|
|
42
|
+
local controller = setup()
|
|
43
|
+
|
|
44
|
+
local promise = controller.service:PromiseDataStore()
|
|
45
|
+
if not PromiseTestUtils.awaitSettled(promise) then
|
|
46
|
+
expect("hung").toEqual("settled")
|
|
47
|
+
controller:destroy()
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
local ok, dataStore = promise:Yield()
|
|
52
|
+
expect(ok).toEqual(true)
|
|
53
|
+
expect(dataStore).never.toBeNil()
|
|
54
|
+
|
|
55
|
+
local loadPromise = dataStore:PromiseLoadSuccessful()
|
|
56
|
+
if not PromiseTestUtils.awaitSettled(loadPromise) then
|
|
57
|
+
expect("hung").toEqual("settled")
|
|
58
|
+
controller:destroy()
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
expect((loadPromise:Wait())).toEqual(true)
|
|
62
|
+
|
|
63
|
+
controller:destroy()
|
|
64
|
+
end)
|
|
65
|
+
|
|
66
|
+
it("should return the same cached promise on repeated calls", function()
|
|
67
|
+
local controller = setup()
|
|
68
|
+
|
|
69
|
+
local first = controller.service:PromiseDataStore()
|
|
70
|
+
local second = controller.service:PromiseDataStore()
|
|
71
|
+
expect((first == second)).toEqual(true)
|
|
72
|
+
|
|
73
|
+
controller:destroy()
|
|
74
|
+
end)
|
|
75
|
+
end)
|
|
76
|
+
|
|
77
|
+
describe("GameDataStoreService persistence", function()
|
|
78
|
+
it("should round-trip a stored value into the mock under the version1 key", function()
|
|
79
|
+
local controller = setup()
|
|
80
|
+
|
|
81
|
+
local promise = controller.service:PromiseDataStore()
|
|
82
|
+
if not PromiseTestUtils.awaitSettled(promise) then
|
|
83
|
+
expect("hung").toEqual("settled")
|
|
84
|
+
controller:destroy()
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
local ok, dataStore = promise:Yield()
|
|
89
|
+
expect(ok).toEqual(true)
|
|
90
|
+
|
|
91
|
+
dataStore:Store("motd", "hello")
|
|
92
|
+
|
|
93
|
+
local savePromise = dataStore:Save()
|
|
94
|
+
if not PromiseTestUtils.awaitSettled(savePromise) then
|
|
95
|
+
expect("hung").toEqual("settled")
|
|
96
|
+
controller:destroy()
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
expect((savePromise:Yield())).toEqual(true)
|
|
100
|
+
|
|
101
|
+
-- Non-session-locking, so the raw persisted value is the plain data table.
|
|
102
|
+
local raw = controller.mock:GetRaw("version1")
|
|
103
|
+
expect(raw).never.toBeNil()
|
|
104
|
+
expect(raw.motd).toEqual("hello")
|
|
105
|
+
|
|
106
|
+
controller:destroy()
|
|
107
|
+
end)
|
|
108
|
+
end)
|
|
109
|
+
|
|
110
|
+
describe("GameDataStoreService.SetRobloxDataStore", function()
|
|
111
|
+
it("should throw when injected twice (already resolved)", function()
|
|
112
|
+
local controller = setup()
|
|
113
|
+
|
|
114
|
+
expect(function()
|
|
115
|
+
controller.service:SetRobloxDataStore(DataStoreMock.new())
|
|
116
|
+
end).toThrow("Already resolved robloxDataStore")
|
|
117
|
+
|
|
118
|
+
controller:destroy()
|
|
119
|
+
end)
|
|
120
|
+
|
|
121
|
+
it("should throw on a non-datastore argument", function()
|
|
122
|
+
local controller = setup()
|
|
123
|
+
|
|
124
|
+
-- isDataStore is validated before the already-resolved check, so a bad arg throws regardless.
|
|
125
|
+
expect(function()
|
|
126
|
+
controller.service:SetRobloxDataStore({})
|
|
127
|
+
end).toThrow("Bad robloxDataStore")
|
|
128
|
+
|
|
129
|
+
expect(function()
|
|
130
|
+
controller.service:SetRobloxDataStore(nil)
|
|
131
|
+
end).toThrow("Bad robloxDataStore")
|
|
132
|
+
|
|
133
|
+
controller:destroy()
|
|
134
|
+
end)
|
|
135
|
+
end)
|
|
136
|
+
|
|
137
|
+
describe("GameDataStoreService failure handling", function()
|
|
138
|
+
it("should resolve load as false (not hang) when the datastore is down", function()
|
|
139
|
+
local mock = DataStoreMock.new()
|
|
140
|
+
mock:FailAllRequests()
|
|
141
|
+
|
|
142
|
+
local controller = setup(mock)
|
|
143
|
+
|
|
144
|
+
local promise = controller.service:PromiseDataStore()
|
|
145
|
+
if not PromiseTestUtils.awaitSettled(promise, 5) then
|
|
146
|
+
expect("hung").toEqual("settled")
|
|
147
|
+
controller:destroy()
|
|
148
|
+
return
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
local ok, dataStore = promise:Yield()
|
|
152
|
+
expect(ok).toEqual(true)
|
|
153
|
+
|
|
154
|
+
-- Non-session-locking: a failing load rejects promptly, so this settles false rather than hanging.
|
|
155
|
+
local loadPromise = dataStore:PromiseLoadSuccessful()
|
|
156
|
+
if not PromiseTestUtils.awaitSettled(loadPromise, 5) then
|
|
157
|
+
expect("hung").toEqual("settled")
|
|
158
|
+
controller:destroy()
|
|
159
|
+
return
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
local loadOk, loadedOk = loadPromise:Yield()
|
|
163
|
+
expect(loadOk).toEqual(true)
|
|
164
|
+
expect(loadedOk).toEqual(false)
|
|
165
|
+
|
|
166
|
+
controller:destroy()
|
|
167
|
+
end)
|
|
168
|
+
end)
|
|
169
|
+
|
|
170
|
+
describe("GameDataStoreService teardown", function()
|
|
171
|
+
it("destroys its inner datastore when the service is destroyed", function()
|
|
172
|
+
local controller = setup()
|
|
173
|
+
|
|
174
|
+
local promise = controller.service:PromiseDataStore()
|
|
175
|
+
if not PromiseTestUtils.awaitSettled(promise, 5) then
|
|
176
|
+
expect("hung").toEqual("settled")
|
|
177
|
+
controller:destroy()
|
|
178
|
+
return
|
|
179
|
+
end
|
|
180
|
+
local _ok, dataStore = promise:Yield()
|
|
181
|
+
|
|
182
|
+
controller:destroy()
|
|
183
|
+
|
|
184
|
+
-- BaseObject.Destroy clears the metatable, so a torn-down store reads a nil metatable.
|
|
185
|
+
expect(getmetatable(dataStore)).toBeNil()
|
|
186
|
+
end)
|
|
187
|
+
|
|
188
|
+
it("flushes staged data synchronously to the underlying store when destroyed", function()
|
|
189
|
+
local controller = setup()
|
|
190
|
+
|
|
191
|
+
local promise = controller.service:PromiseDataStore()
|
|
192
|
+
if not PromiseTestUtils.awaitSettled(promise, 5) then
|
|
193
|
+
expect("hung").toEqual("settled")
|
|
194
|
+
controller:destroy()
|
|
195
|
+
return
|
|
196
|
+
end
|
|
197
|
+
local _ok, dataStore = promise:Yield()
|
|
198
|
+
|
|
199
|
+
if not PromiseTestUtils.awaitSettled(dataStore:PromiseLoadSuccessful(), 5) then
|
|
200
|
+
expect("load hung").toEqual("load settled")
|
|
201
|
+
controller:destroy()
|
|
202
|
+
return
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
dataStore:Store("motd", "goodbye")
|
|
206
|
+
|
|
207
|
+
-- Destroy must fire a synchronous Save before tearing the store down, so the value is
|
|
208
|
+
-- already in the store the instant destroy() returns -- asserted with no awaiting.
|
|
209
|
+
controller:destroy()
|
|
210
|
+
|
|
211
|
+
local raw = controller.mock:GetRaw("version1")
|
|
212
|
+
expect(raw).never.toBeNil()
|
|
213
|
+
expect(raw.motd).toEqual("goodbye")
|
|
214
|
+
end)
|
|
215
|
+
end)
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[=[
|
|
3
|
+
In-memory stand-in for a Roblox `GlobalDataStore` used by tests. It faithfully
|
|
4
|
+
round-trips values through a deep copy (mimicking JSON serialization, so aliasing
|
|
5
|
+
bugs surface the same way they would against a real datastore) and lets tests inject
|
|
6
|
+
failures such as the `509` Personal-RCC block.
|
|
7
|
+
|
|
8
|
+
It is a first-class citizen of the datastore package: the `DataStorePromises` wrappers
|
|
9
|
+
accept it anywhere a real datastore `Instance` is expected via
|
|
10
|
+
[DataStoreMock.isDataStoreMock].
|
|
11
|
+
|
|
12
|
+
```lua
|
|
13
|
+
local store = DataStoreMock.new("PlayerData", "SaveData")
|
|
14
|
+
store:SetRaw("key", { coins = 5 })
|
|
15
|
+
|
|
16
|
+
-- Simulate Roblox datastores being down
|
|
17
|
+
store:FailAllRequests(DataStoreMock.OPERATION_NOT_ALLOWED_509)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
@server
|
|
21
|
+
@class DataStoreMock
|
|
22
|
+
]=]
|
|
23
|
+
|
|
24
|
+
local require = require(script.Parent.loader).load(script)
|
|
25
|
+
|
|
26
|
+
local Table = require("Table")
|
|
27
|
+
|
|
28
|
+
local function deepCopy(value: any): any
|
|
29
|
+
if type(value) ~= "table" then
|
|
30
|
+
return value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
return Table.deepCopy(value)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
local DataStoreMock = {}
|
|
37
|
+
DataStoreMock.ClassName = "DataStoreMock"
|
|
38
|
+
DataStoreMock.__index = DataStoreMock
|
|
39
|
+
|
|
40
|
+
--[=[
|
|
41
|
+
The error Roblox raises when datastore operations run on a Personal RCC. This is the
|
|
42
|
+
real-world failure that motivated the mock.
|
|
43
|
+
@prop OPERATION_NOT_ALLOWED_509 string
|
|
44
|
+
@within DataStoreMock
|
|
45
|
+
]=]
|
|
46
|
+
DataStoreMock.OPERATION_NOT_ALLOWED_509 =
|
|
47
|
+
"509: Data Store operations blocked while running on a Personal RCC to prevent possible data corruption"
|
|
48
|
+
|
|
49
|
+
export type ErrorInjectorContext = {
|
|
50
|
+
method: string,
|
|
51
|
+
key: string,
|
|
52
|
+
callIndex: number,
|
|
53
|
+
mock: DataStoreMock,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type ErrorInjector = (ErrorInjectorContext) -> string?
|
|
57
|
+
|
|
58
|
+
export type DataStoreMock = typeof(setmetatable(
|
|
59
|
+
{} :: {
|
|
60
|
+
_name: string,
|
|
61
|
+
_scope: string,
|
|
62
|
+
_store: { [string]: any },
|
|
63
|
+
_userIds: { [string]: { number }? },
|
|
64
|
+
_metadata: { [string]: { [string]: any } },
|
|
65
|
+
_versions: { [string]: number },
|
|
66
|
+
_callCounts: { [string]: number },
|
|
67
|
+
_totalCalls: number,
|
|
68
|
+
_yieldTime: number,
|
|
69
|
+
_errorInjector: ErrorInjector?,
|
|
70
|
+
_blocked: boolean,
|
|
71
|
+
},
|
|
72
|
+
{} :: typeof({ __index = DataStoreMock })
|
|
73
|
+
))
|
|
74
|
+
|
|
75
|
+
--[=[
|
|
76
|
+
Returns whether the given value is a [DataStoreMock]. Used by [DataStorePromises] so the
|
|
77
|
+
mock can stand in for a real datastore `Instance`.
|
|
78
|
+
|
|
79
|
+
@param value any
|
|
80
|
+
@return boolean
|
|
81
|
+
]=]
|
|
82
|
+
function DataStoreMock.isDataStoreMock(value: any): boolean
|
|
83
|
+
return type(value) == "table" and getmetatable(value) == DataStoreMock
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
--[=[
|
|
87
|
+
Constructs a new DataStoreMock.
|
|
88
|
+
|
|
89
|
+
@param name string?
|
|
90
|
+
@param scope string?
|
|
91
|
+
@return DataStoreMock
|
|
92
|
+
]=]
|
|
93
|
+
function DataStoreMock.new(name: string?, scope: string?): DataStoreMock
|
|
94
|
+
local self = setmetatable({}, DataStoreMock)
|
|
95
|
+
|
|
96
|
+
self._name = name or "MockDataStore"
|
|
97
|
+
self._scope = scope or "global"
|
|
98
|
+
|
|
99
|
+
self._store = {}
|
|
100
|
+
self._userIds = {}
|
|
101
|
+
self._metadata = {}
|
|
102
|
+
self._versions = {}
|
|
103
|
+
|
|
104
|
+
self._callCounts = {}
|
|
105
|
+
self._totalCalls = 0
|
|
106
|
+
self._yieldTime = 0
|
|
107
|
+
self._errorInjector = nil
|
|
108
|
+
self._blocked = false
|
|
109
|
+
|
|
110
|
+
return self
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
--[=[
|
|
114
|
+
Sets how long (in seconds) each request yields before completing, to mimic real
|
|
115
|
+
datastore latency. Defaults to 0 (no yield) so tests stay fast.
|
|
116
|
+
|
|
117
|
+
@param yieldTime number
|
|
118
|
+
]=]
|
|
119
|
+
function DataStoreMock.SetYieldTime(self: DataStoreMock, yieldTime: number): ()
|
|
120
|
+
assert(type(yieldTime) == "number" and yieldTime >= 0, "Bad yieldTime")
|
|
121
|
+
|
|
122
|
+
self._yieldTime = yieldTime
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
--[=[
|
|
126
|
+
Injects a callback consulted before every request. Returning a string from the callback
|
|
127
|
+
makes that request throw the string as its error; returning nil lets the request proceed.
|
|
128
|
+
|
|
129
|
+
@param errorInjector ((ErrorInjectorContext) -> string?)?
|
|
130
|
+
]=]
|
|
131
|
+
function DataStoreMock.SetErrorInjector(self: DataStoreMock, errorInjector: ErrorInjector?): ()
|
|
132
|
+
assert(type(errorInjector) == "function" or errorInjector == nil, "Bad errorInjector")
|
|
133
|
+
|
|
134
|
+
self._errorInjector = errorInjector
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
--[=[
|
|
138
|
+
Makes every subsequent request throw the given error until [DataStoreMock.StopFailing]
|
|
139
|
+
is called. Simulates a total datastore outage.
|
|
140
|
+
|
|
141
|
+
@param errorMessage string? -- Defaults to the 509 Personal-RCC error
|
|
142
|
+
]=]
|
|
143
|
+
function DataStoreMock.FailAllRequests(self: DataStoreMock, errorMessage: string?): ()
|
|
144
|
+
local message = errorMessage or DataStoreMock.OPERATION_NOT_ALLOWED_509
|
|
145
|
+
assert(type(message) == "string", "Bad errorMessage")
|
|
146
|
+
|
|
147
|
+
self:SetErrorInjector(function()
|
|
148
|
+
return message
|
|
149
|
+
end)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
--[=[
|
|
153
|
+
Makes the next `count` requests throw the given error, then recover. Simulates a
|
|
154
|
+
transient outage that the retry logic is expected to survive.
|
|
155
|
+
|
|
156
|
+
@param count number
|
|
157
|
+
@param errorMessage string? -- Defaults to the 509 Personal-RCC error
|
|
158
|
+
]=]
|
|
159
|
+
function DataStoreMock.FailNextRequests(self: DataStoreMock, count: number, errorMessage: string?): ()
|
|
160
|
+
assert(type(count) == "number" and count >= 0, "Bad count")
|
|
161
|
+
local message = errorMessage or DataStoreMock.OPERATION_NOT_ALLOWED_509
|
|
162
|
+
assert(type(message) == "string", "Bad errorMessage")
|
|
163
|
+
|
|
164
|
+
local remaining = count
|
|
165
|
+
self:SetErrorInjector(function()
|
|
166
|
+
if remaining > 0 then
|
|
167
|
+
remaining -= 1
|
|
168
|
+
return message
|
|
169
|
+
end
|
|
170
|
+
return nil
|
|
171
|
+
end)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
--[=[
|
|
175
|
+
Clears any injected failures.
|
|
176
|
+
]=]
|
|
177
|
+
function DataStoreMock.StopFailing(self: DataStoreMock): ()
|
|
178
|
+
self._errorInjector = nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
--[=[
|
|
182
|
+
Makes every subsequent request hang (yield) inside the datastore call until
|
|
183
|
+
[DataStoreMock.UnblockRequests] is called. Simulates a request that does not settle -- e.g. a
|
|
184
|
+
lock command that can take up to ~30s to propagate across servers -- so tests can exercise a
|
|
185
|
+
request in flight (and its maid cancelling the yielding thread).
|
|
186
|
+
]=]
|
|
187
|
+
function DataStoreMock.BlockRequests(self: DataStoreMock): ()
|
|
188
|
+
self._blocked = true
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
--[=[
|
|
192
|
+
Releases requests blocked by [DataStoreMock.BlockRequests]. A request whose thread was cancelled
|
|
193
|
+
while blocked never resumes.
|
|
194
|
+
]=]
|
|
195
|
+
function DataStoreMock.UnblockRequests(self: DataStoreMock): ()
|
|
196
|
+
self._blocked = false
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
--[=[
|
|
200
|
+
Returns the number of times a given API was called (or total across all APIs when no
|
|
201
|
+
method is given). Failed calls count too.
|
|
202
|
+
|
|
203
|
+
@param method string? -- e.g. "GetAsync", "UpdateAsync"
|
|
204
|
+
@return number
|
|
205
|
+
]=]
|
|
206
|
+
function DataStoreMock.GetCallCount(self: DataStoreMock, method: string?): number
|
|
207
|
+
if method == nil then
|
|
208
|
+
return self._totalCalls
|
|
209
|
+
end
|
|
210
|
+
return self._callCounts[method] or 0
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
--[=[
|
|
214
|
+
Directly seeds a stored value without datastore semantics (no version bump, no failure
|
|
215
|
+
injection). For test setup.
|
|
216
|
+
|
|
217
|
+
@param key string
|
|
218
|
+
@param value any
|
|
219
|
+
]=]
|
|
220
|
+
function DataStoreMock.SetRaw(self: DataStoreMock, key: string, value: any): ()
|
|
221
|
+
assert(type(key) == "string", "Bad key")
|
|
222
|
+
|
|
223
|
+
self._store[key] = deepCopy(value)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
--[=[
|
|
227
|
+
Directly reads a stored value without datastore semantics. For test assertions.
|
|
228
|
+
|
|
229
|
+
@param key string
|
|
230
|
+
@return any
|
|
231
|
+
]=]
|
|
232
|
+
function DataStoreMock.GetRaw(self: DataStoreMock, key: string): any
|
|
233
|
+
assert(type(key) == "string", "Bad key")
|
|
234
|
+
|
|
235
|
+
return deepCopy(self._store[key])
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
function DataStoreMock._beginRequest(self: DataStoreMock, method: string, key: string): ()
|
|
239
|
+
self._callCounts[method] = (self._callCounts[method] or 0) + 1
|
|
240
|
+
self._totalCalls += 1
|
|
241
|
+
|
|
242
|
+
if self._yieldTime > 0 then
|
|
243
|
+
task.wait(self._yieldTime)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
-- Hang here until unblocked (or until the calling thread is cancelled by its maid).
|
|
247
|
+
while self._blocked do
|
|
248
|
+
task.wait()
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
if self._errorInjector then
|
|
252
|
+
local errorMessage = self._errorInjector({
|
|
253
|
+
method = method,
|
|
254
|
+
key = key,
|
|
255
|
+
callIndex = self._totalCalls,
|
|
256
|
+
mock = self,
|
|
257
|
+
})
|
|
258
|
+
if errorMessage then
|
|
259
|
+
error(errorMessage, 0)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
function DataStoreMock._makeKeyInfo(self: DataStoreMock, key: string)
|
|
265
|
+
local userIds = self._userIds[key]
|
|
266
|
+
local metadata = self._metadata[key]
|
|
267
|
+
local version = self._versions[key] or 0
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
Version = tostring(version),
|
|
271
|
+
CreatedTime = 0,
|
|
272
|
+
UpdatedTime = 0,
|
|
273
|
+
GetUserIds = function()
|
|
274
|
+
return Table.deepCopy(userIds or {})
|
|
275
|
+
end,
|
|
276
|
+
GetMetadata = function()
|
|
277
|
+
return Table.deepCopy(metadata or {})
|
|
278
|
+
end,
|
|
279
|
+
}
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
--[=[
|
|
283
|
+
Mimics `GlobalDataStore:GetAsync`.
|
|
284
|
+
|
|
285
|
+
@param key string
|
|
286
|
+
@return (any, any) -- value, keyInfo
|
|
287
|
+
]=]
|
|
288
|
+
function DataStoreMock.GetAsync(self: DataStoreMock, key: string): (any, any)
|
|
289
|
+
assert(type(key) == "string", "Bad key")
|
|
290
|
+
|
|
291
|
+
self:_beginRequest("GetAsync", key)
|
|
292
|
+
|
|
293
|
+
return deepCopy(self._store[key]), self:_makeKeyInfo(key)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
--[=[
|
|
297
|
+
Mimics `GlobalDataStore:SetAsync`.
|
|
298
|
+
|
|
299
|
+
@param key string
|
|
300
|
+
@param value any
|
|
301
|
+
@param userIds { number }?
|
|
302
|
+
@param options any?
|
|
303
|
+
@return string -- version
|
|
304
|
+
]=]
|
|
305
|
+
function DataStoreMock.SetAsync(
|
|
306
|
+
self: DataStoreMock,
|
|
307
|
+
key: string,
|
|
308
|
+
value: any,
|
|
309
|
+
userIds: { number }?,
|
|
310
|
+
options: any?
|
|
311
|
+
): string
|
|
312
|
+
assert(type(key) == "string", "Bad key")
|
|
313
|
+
|
|
314
|
+
self:_beginRequest("SetAsync", key)
|
|
315
|
+
|
|
316
|
+
self._store[key] = deepCopy(value)
|
|
317
|
+
self._userIds[key] = userIds and Table.deepCopy(userIds) or nil
|
|
318
|
+
if options and options.GetMetadata then
|
|
319
|
+
self._metadata[key] = options:GetMetadata()
|
|
320
|
+
end
|
|
321
|
+
self._versions[key] = (self._versions[key] or 0) + 1
|
|
322
|
+
|
|
323
|
+
return tostring(self._versions[key])
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
--[=[
|
|
327
|
+
Mimics `GlobalDataStore:UpdateAsync`. The transform receives the current value and a
|
|
328
|
+
key-info stand-in, and returns `newValue [, userIds [, metadata]]`. Returning nil cancels
|
|
329
|
+
the update (matching Roblox semantics).
|
|
330
|
+
|
|
331
|
+
@param key string
|
|
332
|
+
@param transformFunction (any, any) -> ...any
|
|
333
|
+
@return (any, any) -- value, keyInfo
|
|
334
|
+
]=]
|
|
335
|
+
function DataStoreMock.UpdateAsync(
|
|
336
|
+
self: DataStoreMock,
|
|
337
|
+
key: string,
|
|
338
|
+
transformFunction: (any, any) -> ...any
|
|
339
|
+
): (any, any)
|
|
340
|
+
assert(type(key) == "string", "Bad key")
|
|
341
|
+
assert(type(transformFunction) == "function", "Bad transformFunction")
|
|
342
|
+
|
|
343
|
+
self:_beginRequest("UpdateAsync", key)
|
|
344
|
+
|
|
345
|
+
local current = deepCopy(self._store[key])
|
|
346
|
+
local keyInfo = self:_makeKeyInfo(key)
|
|
347
|
+
|
|
348
|
+
local newValue, userIds, metadata = transformFunction(current, keyInfo)
|
|
349
|
+
if newValue == nil then
|
|
350
|
+
-- Update cancelled; nothing written
|
|
351
|
+
return nil, keyInfo
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
self._store[key] = deepCopy(newValue)
|
|
355
|
+
self._userIds[key] = userIds and Table.deepCopy(userIds) or nil
|
|
356
|
+
self._metadata[key] = metadata and Table.deepCopy(metadata) or nil
|
|
357
|
+
self._versions[key] = (self._versions[key] or 0) + 1
|
|
358
|
+
|
|
359
|
+
return deepCopy(self._store[key]), self:_makeKeyInfo(key)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
--[=[
|
|
363
|
+
Mimics `GlobalDataStore:RemoveAsync`.
|
|
364
|
+
|
|
365
|
+
@param key string
|
|
366
|
+
@return (any, any) -- removed value, keyInfo
|
|
367
|
+
]=]
|
|
368
|
+
function DataStoreMock.RemoveAsync(self: DataStoreMock, key: string): (any, any)
|
|
369
|
+
assert(type(key) == "string", "Bad key")
|
|
370
|
+
|
|
371
|
+
self:_beginRequest("RemoveAsync", key)
|
|
372
|
+
|
|
373
|
+
local removed = deepCopy(self._store[key])
|
|
374
|
+
local keyInfo = self:_makeKeyInfo(key)
|
|
375
|
+
|
|
376
|
+
self._store[key] = nil
|
|
377
|
+
self._userIds[key] = nil
|
|
378
|
+
self._metadata[key] = nil
|
|
379
|
+
|
|
380
|
+
return removed, keyInfo
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
--[=[
|
|
384
|
+
Mimics `GlobalDataStore:IncrementAsync`.
|
|
385
|
+
|
|
386
|
+
@param key string
|
|
387
|
+
@param delta number?
|
|
388
|
+
@return number
|
|
389
|
+
]=]
|
|
390
|
+
function DataStoreMock.IncrementAsync(self: DataStoreMock, key: string, delta: number?): number
|
|
391
|
+
assert(type(key) == "string", "Bad key")
|
|
392
|
+
|
|
393
|
+
self:_beginRequest("IncrementAsync", key)
|
|
394
|
+
|
|
395
|
+
local current = self._store[key]
|
|
396
|
+
assert(current == nil or type(current) == "number", "Cannot increment non-number value")
|
|
397
|
+
|
|
398
|
+
local newValue = (current or 0) + (delta or 1)
|
|
399
|
+
self._store[key] = newValue
|
|
400
|
+
self._versions[key] = (self._versions[key] or 0) + 1
|
|
401
|
+
|
|
402
|
+
return newValue
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
return DataStoreMock
|