@quenty/datastore 13.39.1 → 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.
Files changed (32) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +9 -9
  3. package/src/Server/DataStore.HookErrors.spec.lua +92 -0
  4. package/src/Server/DataStore.LoadErrors.spec.lua +133 -0
  5. package/src/Server/DataStore.Reentrance.spec.lua +119 -0
  6. package/src/Server/DataStore.SessionLock.spec.lua +478 -0
  7. package/src/Server/DataStore.SessionMessaging.spec.lua +71 -0
  8. package/src/Server/DataStore.TwoServerLock.spec.lua +190 -0
  9. package/src/Server/DataStore.lua +89 -18
  10. package/src/Server/DataStore.spec.lua +237 -0
  11. package/src/Server/DataStoreLockHelper.spec.lua +239 -0
  12. package/src/Server/DataStoreMessageHelper.spec.lua +134 -0
  13. package/src/Server/DataStoreNonRetryableLoadError.lua +61 -0
  14. package/src/Server/DataStoreTestUtils.lua +206 -0
  15. package/src/Server/GameDataStoreService.lua +26 -0
  16. package/src/Server/GameDataStoreService.spec.lua +215 -0
  17. package/src/Server/Mocks/DataStoreMock.lua +405 -0
  18. package/src/Server/Mocks/DataStoreMock.spec.lua +384 -0
  19. package/src/Server/Modules/DataStoreSnapshotUtils.spec.lua +91 -0
  20. package/src/Server/Modules/DataStoreStage.lua +8 -2
  21. package/src/Server/Modules/DataStoreStage.spec.lua +539 -0
  22. package/src/Server/Modules/DataStoreWriter.spec.lua +399 -0
  23. package/src/Server/PlayerDataStoreManager.RemovalCallbacks.spec.lua +99 -0
  24. package/src/Server/PlayerDataStoreManager.lua +36 -4
  25. package/src/Server/PlayerDataStoreManager.spec.lua +202 -0
  26. package/src/Server/PlayerDataStoreService.lua +18 -0
  27. package/src/Server/PlayerDataStoreService.spec.lua +218 -0
  28. package/src/Server/PrivateServerDataStoreService.lua +23 -1
  29. package/src/Server/PrivateServerDataStoreService.spec.lua +242 -0
  30. package/src/Server/Utility/DataStorePromises.lua +18 -5
  31. package/src/Server/Utility/DataStorePromises.spec.lua +363 -0
  32. 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