@quenty/datastore 13.51.1 → 13.52.1

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.
@@ -0,0 +1,370 @@
1
+ --!strict
2
+ --[[
3
+ Drives the command bodies directly. The service is built by hand rather than through a ServiceBag
4
+ so the real CmdrService (and the Cmdr instance tree behind it) stays out of the test place --
5
+ what is worth checking here is what the commands say and what they write, not Cmdr's plumbing.
6
+
7
+ Command bodies take the userId list Cmdr's `playerIds` type parses, so these pass one directly.
8
+
9
+ @class DataStoreCmdrService.spec.lua
10
+ ]]
11
+ local require = require(script.Parent.loader).load(script)
12
+
13
+ local HttpService = game:GetService("HttpService")
14
+
15
+ local CmdrReplyUtils = require("CmdrReplyUtils")
16
+ local DataStoreCmdrService = require("DataStoreCmdrService")
17
+ local DataStoreTestUtils = require("DataStoreTestUtils")
18
+ local Jest = require("Jest")
19
+ local Maid = require("Maid")
20
+ local Promise = require("Promise")
21
+
22
+ local describe = Jest.Globals.describe
23
+ local expect = Jest.Globals.expect
24
+ local it = Jest.Globals.it
25
+
26
+ -- Short enough that the progress tests do not have to sit through the real threshold.
27
+ local SLOW_REPLY_SECONDS = 0.05
28
+
29
+ local FOREIGN_LOCK = {
30
+ LastUpdateTime = os.time(),
31
+ ActiveSession = {
32
+ SessionId = "foreign-session",
33
+ PlaceId = 123,
34
+ JobId = "foreign-job",
35
+ },
36
+ }
37
+
38
+ local function setup()
39
+ local controller = DataStoreTestUtils.setupDataStoreManager()
40
+
41
+ local registered = {}
42
+ local registeredTypes = {}
43
+ local cmdr = {
44
+ Registry = {
45
+ RegisterType = function(_self, name, definition)
46
+ registeredTypes[name] = definition
47
+ end,
48
+ },
49
+ }
50
+ local cmdrService = {
51
+ RegisterCommand = function(_self, definition, execute)
52
+ -- CmdrService ships the definition to the client as JSON, so a definition it cannot encode
53
+ -- fails in-game at registration. Encode it here, where the failure is a test result.
54
+ HttpService:JSONEncode(definition)
55
+ registered[definition.Name] = execute
56
+ end,
57
+ PromiseCmdr = function()
58
+ return Promise.resolved(cmdr)
59
+ end,
60
+ }
61
+
62
+ local replies: { string } = {}
63
+ local context = {
64
+ Reply = function(_self, text: string)
65
+ table.insert(replies, text)
66
+ end,
67
+ }
68
+
69
+ local serviceMaid = Maid.new()
70
+ local service = setmetatable({
71
+ _maid = serviceMaid,
72
+ _cmdrService = cmdrService,
73
+ _playerDataStoreService = {
74
+ PromiseManager = function()
75
+ return Promise.resolved(controller.manager)
76
+ end,
77
+ },
78
+ }, { __index = DataStoreCmdrService }) :: any
79
+
80
+ service:SetReplyConfig(CmdrReplyUtils.createConfig({ slowReplySeconds = SLOW_REPLY_SECONDS }))
81
+ service:Start()
82
+
83
+ return {
84
+ manager = controller.manager,
85
+ mock = controller.mock,
86
+ storeAndAwaitLock = controller.storeAndAwaitLock,
87
+ subStoreType = function()
88
+ return registeredTypes.dataStoreSubStore
89
+ end,
90
+ run = function(commandName: string, ...)
91
+ return registered[commandName](context, ...)
92
+ end,
93
+ replies = replies,
94
+ registeredNames = function()
95
+ local names = {}
96
+ for name, _ in registered do
97
+ table.insert(names, name)
98
+ end
99
+ table.sort(names)
100
+ return names
101
+ end,
102
+ destroy = function(_self)
103
+ serviceMaid:DoCleaning()
104
+ controller:destroy()
105
+ end,
106
+ }
107
+ end
108
+
109
+ describe("DataStoreCmdrService registration", function()
110
+ it("registers the lock and data commands", function()
111
+ local controller = setup()
112
+
113
+ expect(controller.registeredNames()).toEqual({
114
+ "datastore-copy",
115
+ "datastore-delete",
116
+ "datastore-lock",
117
+ "datastore-lock-info",
118
+ "datastore-read-json",
119
+ "datastore-unlock",
120
+ "datastore-write-json",
121
+ })
122
+
123
+ controller:destroy()
124
+ end)
125
+
126
+ it("registers the sub-store type", function()
127
+ local controller = setup()
128
+
129
+ expect(controller.subStoreType()).never.toBeNil()
130
+
131
+ controller:destroy()
132
+ end)
133
+
134
+ it("takes players in bulk", function()
135
+ local controller = setup()
136
+
137
+ expect(controller.run("datastore-lock-info", {})).toEqual("No players to act on.")
138
+
139
+ controller:destroy()
140
+ end)
141
+
142
+ it("stays quiet while a command is quick", function()
143
+ local controller = setup()
144
+
145
+ controller.run("datastore-lock-info", { 1 })
146
+ task.wait(SLOW_REPLY_SECONDS * 2)
147
+
148
+ expect(#controller.replies).toEqual(0)
149
+
150
+ controller:destroy()
151
+ end)
152
+
153
+ it("reports a target that is taking a while", function()
154
+ local controller = setup()
155
+
156
+ controller.mock:SetYieldTime(SLOW_REPLY_SECONDS * 2)
157
+
158
+ controller.run("datastore-lock-info", { 1 })
159
+
160
+ expect(#controller.replies).toEqual(1)
161
+ expect(string.find(controller.replies[1], "1: ", 1, true) ~= nil).toEqual(true)
162
+
163
+ controller:destroy()
164
+ end)
165
+ end)
166
+
167
+ describe("datastore-lock-info", function()
168
+ it("reports an unlocked key", function()
169
+ local controller = setup()
170
+
171
+ expect(string.find(controller.run("datastore-lock-info", { 1 }), "unlocked") ~= nil).toEqual(true)
172
+
173
+ controller:destroy()
174
+ end)
175
+
176
+ it("names the session holding the lock", function()
177
+ local controller = setup()
178
+
179
+ controller.mock:SetRaw("user_1", { coins = 5, lock = FOREIGN_LOCK })
180
+
181
+ expect(string.find(controller.run("datastore-lock-info", { 1 }), "foreign%-job") ~= nil).toEqual(true)
182
+
183
+ controller:destroy()
184
+ end)
185
+
186
+ it("reports every target on its own line", function()
187
+ local controller = setup()
188
+
189
+ controller.mock:SetRaw("user_1", { coins = 5, lock = FOREIGN_LOCK })
190
+ controller.mock:SetRaw("user_2", { coins = 5 })
191
+
192
+ local output = controller.run("datastore-lock-info", { 1, 2 })
193
+ expect(string.find(output, "1: ") ~= nil).toEqual(true)
194
+ expect(string.find(output, "2: ") ~= nil).toEqual(true)
195
+
196
+ controller:destroy()
197
+ end)
198
+ end)
199
+
200
+ describe("datastore-unlock", function()
201
+ it("clears a foreign lock", function()
202
+ local controller = setup()
203
+
204
+ controller.mock:SetRaw("user_1", { coins = 5, lock = FOREIGN_LOCK })
205
+
206
+ local output = controller.run("datastore-unlock", { 1 })
207
+ expect(string.find(output, "Unlocked") ~= nil).toEqual(true)
208
+ expect(controller.mock:GetRaw("user_1").lock).toBeNil()
209
+ expect(controller.mock:GetRaw("user_1").coins).toEqual(5)
210
+
211
+ controller:destroy()
212
+ end)
213
+
214
+ it("says so when there was nothing to clear", function()
215
+ local controller = setup()
216
+
217
+ controller.mock:SetRaw("user_1", { coins = 5 })
218
+
219
+ expect(string.find(controller.run("datastore-unlock", { 1 }), "already unlocked") ~= nil).toEqual(true)
220
+
221
+ controller:destroy()
222
+ end)
223
+
224
+ -- These are stress-test tools, so a live local session is a target rather than a refusal.
225
+ it("clears a key this server holds a live session for", function()
226
+ local controller = setup()
227
+
228
+ if not controller.storeAndAwaitLock() then
229
+ expect("lock was never acquired").toEqual("lock was acquired")
230
+ controller:destroy()
231
+ return
232
+ end
233
+
234
+ expect(string.find(controller.run("datastore-unlock", { 1 }), "Unlocked") ~= nil).toEqual(true)
235
+ expect(controller.mock:GetRaw("user_1").lock).toBeNil()
236
+
237
+ controller:destroy()
238
+ end)
239
+
240
+ it("clears every target in a batch", function()
241
+ local controller = setup()
242
+
243
+ controller.mock:SetRaw("user_1", { coins = 5, lock = FOREIGN_LOCK })
244
+ controller.mock:SetRaw("user_2", { coins = 5, lock = FOREIGN_LOCK })
245
+
246
+ local output = controller.run("datastore-unlock", { 1, 2 })
247
+ expect(string.find(output, "Unlocked 1") ~= nil).toEqual(true)
248
+ expect(string.find(output, "Unlocked 2") ~= nil).toEqual(true)
249
+ expect(controller.mock:GetRaw("user_1").lock).toBeNil()
250
+ expect(controller.mock:GetRaw("user_2").lock).toBeNil()
251
+
252
+ controller:destroy()
253
+ end)
254
+ end)
255
+
256
+ describe("datastore-lock", function()
257
+ it("claims an unlocked key", function()
258
+ local controller = setup()
259
+
260
+ controller.mock:SetRaw("user_1", { coins = 5 })
261
+
262
+ expect(string.find(controller.run("datastore-lock", { 1 }), "Locked") ~= nil).toEqual(true)
263
+ expect(controller.mock:GetRaw("user_1").lock).never.toBeNil()
264
+
265
+ controller:destroy()
266
+ end)
267
+
268
+ it("reports the lock it replaced", function()
269
+ local controller = setup()
270
+
271
+ controller.mock:SetRaw("user_1", { coins = 5, lock = FOREIGN_LOCK })
272
+
273
+ expect(string.find(controller.run("datastore-lock", { 1 }), "foreign%-job") ~= nil).toEqual(true)
274
+
275
+ controller:destroy()
276
+ end)
277
+ end)
278
+
279
+ -- The data commands scope to a sub-store rather than the root wherever they can, so a write under
280
+ -- test cannot clobber the session lock the manager is keeping on the same key.
281
+ describe("datastore-read-json", function()
282
+ it("reads the stored data back as JSON", function()
283
+ local controller = setup()
284
+
285
+ controller.mock:SetRaw("user_1", { profile = { level = 7 } })
286
+
287
+ local output = controller.run("datastore-read-json", { 1 }, { "profile" })
288
+ expect(string.find(output, '"level"') ~= nil).toEqual(true)
289
+ expect(string.find(output, "7") ~= nil).toEqual(true)
290
+
291
+ controller:destroy()
292
+ end)
293
+ end)
294
+
295
+ describe("datastore-write-json", function()
296
+ it("writes a sub-store", function()
297
+ local controller = setup()
298
+
299
+ controller.mock:SetRaw("user_1", { coins = 5 })
300
+
301
+ controller.run("datastore-write-json", { 1 }, '{"level":7}', { "profile" })
302
+ expect(controller.mock:GetRaw("user_1").profile.level).toEqual(7)
303
+ expect(controller.mock:GetRaw("user_1").coins).toEqual(5)
304
+
305
+ controller:destroy()
306
+ end)
307
+
308
+ it("reports undecodable JSON as a failure", function()
309
+ local controller = setup()
310
+
311
+ expect(string.find(controller.run("datastore-write-json", { 1 }, "not json", nil), "Failed") ~= nil).toEqual(
312
+ true
313
+ )
314
+
315
+ controller:destroy()
316
+ end)
317
+ end)
318
+
319
+ describe("datastore-delete", function()
320
+ it("wipes a sub-store and leaves the rest of the key", function()
321
+ local controller = setup()
322
+
323
+ controller.mock:SetRaw("user_1", { coins = 5, profile = { level = 7 } })
324
+
325
+ controller.run("datastore-delete", { 1 }, { "profile" })
326
+ expect(controller.mock:GetRaw("user_1").profile).toBeNil()
327
+ expect(controller.mock:GetRaw("user_1").coins).toEqual(5)
328
+
329
+ controller:destroy()
330
+ end)
331
+ end)
332
+
333
+ describe("datastore-copy", function()
334
+ it("copies a sub-store onto another player", function()
335
+ local controller = setup()
336
+
337
+ controller.mock:SetRaw("user_1", { profile = { level = 7 } })
338
+ controller.mock:SetRaw("user_2", { profile = { level = 1 } })
339
+
340
+ controller.run("datastore-copy", 1, { 2 }, { "profile" })
341
+ expect(controller.mock:GetRaw("user_2").profile.level).toEqual(7)
342
+
343
+ controller:destroy()
344
+ end)
345
+
346
+ it("copies onto an absent player through a store that yields", function()
347
+ local controller = setup()
348
+
349
+ controller.mock:SetRaw("user_1", { profile = { level = 7 } })
350
+ controller.mock:SetRaw("user_2", { profile = { level = 1 } })
351
+ controller.mock:SetYieldTime(0.05)
352
+
353
+ controller.run("datastore-copy", 1, { 2 }, { "profile" })
354
+
355
+ expect(controller.mock:GetRaw("user_2").profile.level).toEqual(7)
356
+
357
+ controller:destroy()
358
+ end)
359
+
360
+ it("skips the source when it is also a target", function()
361
+ local controller = setup()
362
+
363
+ controller.mock:SetRaw("user_1", { profile = { level = 7 } })
364
+
365
+ local output = controller.run("datastore-copy", 1, { 1 }, { "profile" })
366
+ expect(string.find(output, "Skipped") ~= nil).toEqual(true)
367
+
368
+ controller:destroy()
369
+ end)
370
+ end)
@@ -345,6 +345,14 @@ function DataStore.SetLoadRetryOptions(self: DataStore, options: PromiseRetryUti
345
345
  self._loadRetryOptions = options
346
346
  end
347
347
 
348
+ --[=[
349
+ Returns the load retry backoff in use.
350
+ @return RetryOptions
351
+ ]=]
352
+ function DataStore.GetLoadRetryOptions(self: DataStore): PromiseRetryUtils.RetryOptions
353
+ return self._loadRetryOptions
354
+ end
355
+
348
356
  --[=[
349
357
  Overrides how long to wait for Roblox to replicate a released lock after a graceful session-close
350
358
  request before retrying the load. Defaults to 5 seconds. Mainly useful for tests.
@@ -357,6 +365,14 @@ function DataStore.SetSessionMessagingCloseDelaySeconds(self: DataStore, seconds
357
365
  self._sessionMessagingCloseDelaySeconds = seconds
358
366
  end
359
367
 
368
+ --[=[
369
+ Returns the post-graceful-close replication delay in use.
370
+ @return number
371
+ ]=]
372
+ function DataStore.GetSessionMessagingCloseDelaySeconds(self: DataStore): number
373
+ return self._sessionMessagingCloseDelaySeconds
374
+ end
375
+
360
376
  --[=[
361
377
  Returns whether the datastore failed.
362
378
  @return boolean
@@ -693,6 +709,11 @@ function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
693
709
  )
694
710
  end
695
711
 
712
+ -- TODO: Bail out when this store was destroyed mid-load instead of erroring here.
713
+ -- Releasing the last handle for an absent player destroys the lock helper while
714
+ -- this UpdateAsync is still outstanding, and a destroyed BaseObject has no
715
+ -- methods left. Roblox aborts the update on a transform error, so this is noise
716
+ -- rather than a lost write.
696
717
  local lockResult = self._sessionLockingEnabledHelper:AcquireLock(data, canStealLock)
697
718
  if not lockResult.isValid then
698
719
  if self._sessionMessagingEnabledHelper and tryMessagingServiceSessionClose then
@@ -8,6 +8,7 @@ local RunService = game:GetService("RunService")
8
8
  local require = require(script.Parent.loader).load(script)
9
9
 
10
10
  local BaseObject = require("BaseObject")
11
+ local DataStoreLockUtils = require("DataStoreLockUtils")
11
12
  local Promise = require("Promise")
12
13
 
13
14
  local DataStoreLockHelper = setmetatable({}, BaseObject)
@@ -27,16 +28,8 @@ export type DataStoreLockHelper =
27
28
  ))
28
29
  & BaseObject.BaseObject
29
30
 
30
- export type LockedSessionData = {
31
- SessionId: string,
32
- PlaceId: number,
33
- JobId: string,
34
- }
35
-
36
- export type LockData = {
37
- LastUpdateTime: number?,
38
- ActiveSession: LockedSessionData?,
39
- }
31
+ export type LockedSessionData = DataStoreLockUtils.LockedSessionData
32
+ export type LockData = DataStoreLockUtils.LockData
40
33
 
41
34
  export type AcquiredValidLockResult = {
42
35
  isValid: true,
@@ -88,7 +81,7 @@ function DataStoreLockHelper.ToUnlockedProfile(self: DataStoreLockHelper, origin
88
81
  unlockedProfile = original,
89
82
  }
90
83
  else
91
- local parsedLockData = self:_deserializeLockData(original.lock)
84
+ local parsedLockData = DataStoreLockUtils.deserializeLockData(original.lock)
92
85
  if parsedLockData == nil or parsedLockData.ActiveSession == nil then
93
86
  return {
94
87
  isValid = true,
@@ -113,50 +106,16 @@ function DataStoreLockHelper.ToUnlockedProfile(self: DataStoreLockHelper, origin
113
106
  end
114
107
 
115
108
  function DataStoreLockHelper.ToRawUnlockedProfile(_self: DataStoreLockHelper, original: any): any
116
- if original == nil then
117
- return {}
118
- elseif type(original) ~= "table" then
119
- warn("[DataStoreLockHelper] - Data session locking is not available for non-table entries")
120
- return original
121
- else
122
- local copy = table.clone(original)
123
- copy.lock = nil
124
- return copy
125
- end
109
+ return DataStoreLockUtils.withLock(original, nil)
126
110
  end
127
111
 
128
112
  function DataStoreLockHelper.ToLockedProfile(self: DataStoreLockHelper, original: any, doCloseSession: boolean?): any
129
113
  if doCloseSession then
130
114
  self._sessionClosedPromise:Resolve()
115
+ return DataStoreLockUtils.withLock(original, nil)
131
116
  end
132
117
 
133
- if original == nil then
134
- if doCloseSession then
135
- return {}
136
- else
137
- return {
138
- lock = {
139
- LastUpdateTime = os.time(),
140
- ActiveSession = self:_ourCurrentSessionData(),
141
- } :: LockData,
142
- }
143
- end
144
- elseif type(original) ~= "table" then
145
- warn("[DataStoreLockHelper] - Data session locking is not available for non-table entries")
146
- return original
147
- else
148
- local copy = table.clone(original)
149
- if doCloseSession then
150
- copy.lock = nil :: LockData?
151
- else
152
- copy.lock = {
153
- LastUpdateTime = os.time(),
154
- ActiveSession = self:_ourCurrentSessionData(),
155
- } :: LockData
156
- end
157
-
158
- return copy
159
- end
118
+ return DataStoreLockUtils.withLock(original, DataStoreLockUtils.createLockData(self:_ourCurrentSessionData()))
160
119
  end
161
120
 
162
121
  function DataStoreLockHelper._isInSession(self: DataStoreLockHelper, sessionData: LockedSessionData): boolean
@@ -182,43 +141,6 @@ function DataStoreLockHelper._ourCurrentSessionData(self: DataStoreLockHelper):
182
141
  }
183
142
  end
184
143
 
185
- function DataStoreLockHelper._deserializeSessionData(_self: DataStoreLockHelper, sessionData: any): LockedSessionData?
186
- if type(sessionData) ~= "table" then
187
- return nil
188
- end
189
-
190
- if type(sessionData.SessionId) ~= "string" then
191
- return nil
192
- end
193
-
194
- if type(sessionData.PlaceId) ~= "number" then
195
- return nil
196
- end
197
-
198
- if type(sessionData.JobId) ~= "string" then
199
- return nil
200
- end
201
-
202
- return {
203
- SessionId = sessionData.SessionId,
204
- PlaceId = sessionData.PlaceId,
205
- JobId = sessionData.JobId,
206
- }
207
- end
208
-
209
- function DataStoreLockHelper._deserializeLockData(self: DataStoreLockHelper, lockData: any): LockData?
210
- if type(lockData) ~= "table" then
211
- return nil
212
- end
213
-
214
- local activeSession: LockedSessionData? = self:_deserializeSessionData(lockData.ActiveSession)
215
-
216
- return {
217
- LastUpdateTime = if type(lockData.LastUpdateTime) == "number" then lockData.LastUpdateTime else nil,
218
- ActiveSession = activeSession,
219
- }
220
- end
221
-
222
144
  function DataStoreLockHelper.PromiseCloseSession(self: DataStoreLockHelper): Promise.Promise<()>
223
145
  return self._sessionClosedPromise
224
146
  end
@@ -245,7 +167,7 @@ function DataStoreLockHelper.AcquireLock(self: DataStoreLockHelper, data: any, c
245
167
  }
246
168
  end
247
169
 
248
- local parsedLockData = self:_deserializeLockData(data.lock)
170
+ local parsedLockData = DataStoreLockUtils.deserializeLockData(data.lock)
249
171
  if parsedLockData == nil or parsedLockData.ActiveSession == nil then
250
172
  return {
251
173
  isValid = true,
@@ -0,0 +1,40 @@
1
+ --!strict
2
+ --[=[
3
+ Entry point for the datastore package. Registers every server-side datastore service, so a
4
+ consumer gets the package in one [ServiceBag.GetService].
5
+
6
+ @server
7
+ @class DataStoreService
8
+ ]=]
9
+
10
+ local require = require(script.Parent.loader).load(script)
11
+
12
+ local ServiceBag = require("ServiceBag")
13
+
14
+ local DataStoreService = {}
15
+ DataStoreService.ServiceName = "DataStoreService"
16
+
17
+ export type DataStoreService = typeof(setmetatable(
18
+ {} :: {
19
+ _serviceBag: ServiceBag.ServiceBag,
20
+ },
21
+ {} :: typeof({ __index = DataStoreService })
22
+ ))
23
+
24
+ function DataStoreService.Init(self: DataStoreService, serviceBag: ServiceBag.ServiceBag)
25
+ assert(not (self :: any)._serviceBag, "Already initialized")
26
+ self._serviceBag = assert(serviceBag, "No serviceBag")
27
+
28
+ -- External
29
+ self._serviceBag:GetService(require("BindToCloseService"))
30
+ self._serviceBag:GetService(require("PlaceMessagingService"))
31
+ self._serviceBag:GetService(require("CmdrService"))
32
+
33
+ -- Internal
34
+ self._serviceBag:GetService(require("DataStoreCmdrService"))
35
+ self._serviceBag:GetService(require("GameDataStoreService"))
36
+ self._serviceBag:GetService(require("PlayerDataStoreService"))
37
+ self._serviceBag:GetService(require("PrivateServerDataStoreService"))
38
+ end
39
+
40
+ return DataStoreService