@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.
@@ -51,16 +51,21 @@
51
51
 
52
52
  local require = require(script.Parent.loader).load(script)
53
53
 
54
+ local HttpService = game:GetService("HttpService")
54
55
  local Players = game:GetService("Players")
55
56
  local RunService = game:GetService("RunService")
56
57
 
57
58
  local BaseObject = require("BaseObject")
58
59
  local BindToCloseService = require("BindToCloseService")
59
60
  local DataStore = require("DataStore")
61
+ local DataStoreLockUtils = require("DataStoreLockUtils")
62
+ local DataStorePromises = require("DataStorePromises")
60
63
  local Maid = require("Maid")
61
64
  local PendingPromiseTracker = require("PendingPromiseTracker")
65
+ local PlayerDataStoreHandle = require("PlayerDataStoreHandle")
62
66
  local PlayerMock = require("PlayerMock")
63
67
  local Promise = require("Promise")
68
+ local PromiseRetryUtils = require("PromiseRetryUtils")
64
69
  local PromiseUtils = require("PromiseUtils")
65
70
  local ServiceBag = require("ServiceBag")
66
71
 
@@ -81,9 +86,15 @@ export type PlayerDataStoreManager =
81
86
  _datastores: { [PlayerUserId]: DataStore.DataStore },
82
87
  _removing: { [PlayerUserId]: boolean },
83
88
  _removingPromises: { [PlayerUserId]: Promise.Promise<any> },
89
+ _handleCounts: { [PlayerUserId]: number },
84
90
  _pendingSaves: PendingPromiseTracker.PendingPromiseTracker<any>,
85
91
  _removingCallbacks: { RemovingCallback },
86
92
  _disableSavingInStudio: boolean?,
93
+ _hasCreatedDataStore: boolean,
94
+ _loadRetryOptions: PromiseRetryUtils.RetryOptions?,
95
+ _autoSaveTimeSeconds: number?,
96
+ _autoSaveTimeSecondsSet: boolean,
97
+ _sessionMessagingCloseDelaySeconds: number?,
87
98
  },
88
99
  {} :: typeof({ __index = PlayerDataStoreManager })
89
100
  ))
@@ -119,8 +130,11 @@ function PlayerDataStoreManager.new(
119
130
  self._datastores = {} -- [userId] = datastore
120
131
  self._removing = {} -- [player] = true
121
132
  self._removingPromises = {} -- [player] = removal promise
133
+ self._handleCounts = {} -- [userId] = outstanding PlayerDataStoreHandle count
122
134
  self._pendingSaves = PendingPromiseTracker.new()
123
135
  self._removingCallbacks = {} -- [func, ...]
136
+ self._hasCreatedDataStore = false
137
+ self._autoSaveTimeSecondsSet = false
124
138
 
125
139
  self._maid:GiveTask(Players.PlayerRemoving:Connect(function(player)
126
140
  if self._disableSavingInStudio then
@@ -155,6 +169,64 @@ function PlayerDataStoreManager.DisableSaveOnCloseStudio(self: PlayerDataStoreMa
155
169
  self._disableSavingInStudio = true
156
170
  end
157
171
 
172
+ --[=[
173
+ Overrides the load retry backoff on every datastore this manager creates. See
174
+ [DataStore.SetLoadRetryOptions].
175
+
176
+ This is the knob that decides how long a player waits on a lock held by a dead server: the ladder
177
+ runs, and only once it is exhausted is the lock stolen unconditionally. Defaults to ~49s.
178
+
179
+ :::info
180
+ Must be set before the first datastore is created.
181
+ :::
182
+
183
+ @param options RetryOptions
184
+ ]=]
185
+ function PlayerDataStoreManager.SetLoadRetryOptions(
186
+ self: PlayerDataStoreManager,
187
+ options: PromiseRetryUtils.RetryOptions
188
+ ): ()
189
+ assert(not self._hasCreatedDataStore, "Must configure before the first datastore is created")
190
+ assert(type(options) == "table", "Bad options")
191
+
192
+ self._loadRetryOptions = options
193
+ end
194
+
195
+ --[=[
196
+ Sets the autosave interval on every datastore this manager creates. See
197
+ [DataStore.SetAutoSaveTimeSeconds]. Passing nil disables syncing entirely.
198
+
199
+ :::info
200
+ Must be set before the first datastore is created.
201
+ :::
202
+
203
+ @param autoSaveTimeSeconds number?
204
+ ]=]
205
+ function PlayerDataStoreManager.SetAutoSaveTimeSeconds(self: PlayerDataStoreManager, autoSaveTimeSeconds: number?): ()
206
+ assert(not self._hasCreatedDataStore, "Must configure before the first datastore is created")
207
+ assert(type(autoSaveTimeSeconds) == "number" or autoSaveTimeSeconds == nil, "Bad autoSaveTimeSeconds")
208
+
209
+ self._autoSaveTimeSeconds = autoSaveTimeSeconds
210
+ self._autoSaveTimeSecondsSet = true
211
+ end
212
+
213
+ --[=[
214
+ Sets the post-graceful-close replication delay on every datastore this manager creates. See
215
+ [DataStore.SetSessionMessagingCloseDelaySeconds].
216
+
217
+ :::info
218
+ Must be set before the first datastore is created.
219
+ :::
220
+
221
+ @param seconds number
222
+ ]=]
223
+ function PlayerDataStoreManager.SetSessionMessagingCloseDelaySeconds(self: PlayerDataStoreManager, seconds: number): ()
224
+ assert(not self._hasCreatedDataStore, "Must configure before the first datastore is created")
225
+ assert(type(seconds) == "number" and seconds >= 0, "Bad seconds")
226
+
227
+ self._sessionMessagingCloseDelaySeconds = seconds
228
+ end
229
+
158
230
  --[=[
159
231
  Adds a callback to be called before save on removal
160
232
  @param callback function -- May return a promise
@@ -177,6 +249,93 @@ function PlayerDataStoreManager.RemovePlayerDataStore(
177
249
  self:_removePlayerDataStore(userId)
178
250
  end
179
251
 
252
+ --[=[
253
+ Gets the datastore for a player as a counted handle, opening a session if none is live.
254
+
255
+ Prefer this over [PlayerDataStoreManager.PromiseDataStore] for anything acting on a player who may
256
+ not be in this server. Opening their store takes the session lock, which kicks them from wherever
257
+ they were and keeps them from rejoining until it is dropped -- and destroying the handle is what
258
+ drops it.
259
+
260
+ Handles are counted, so several systems can hold the same player's store at once and the session
261
+ survives until the last handle is destroyed.
262
+
263
+ :::note
264
+ The join/leave path deliberately does *not* run through handles. Making a player's presence just
265
+ another reference would be tidier, but removal is reached from several directions already -- a
266
+ stolen session, a close request, a failed lock, PlayerRemoving, server shutdown -- and a handle
267
+ leaked on any of them would hold a player's save open instead of closing it, which is worse than
268
+ the asymmetry. So a handle never removes a store belonging to a player who is in this server;
269
+ their own path owns that.
270
+ :::
271
+
272
+ @param playerOrUserId Player | number
273
+ @return Promise<PlayerDataStoreHandle>
274
+ ]=]
275
+ function PlayerDataStoreManager.PromiseDataStoreHandle(
276
+ self: PlayerDataStoreManager,
277
+ playerOrUserId: Player | PlayerUserId
278
+ ): Promise.Promise<PlayerDataStoreHandle.PlayerDataStoreHandle>
279
+ local userId = self:_toPlayerUserIdOrError(playerOrUserId)
280
+
281
+ -- Counted before the open rather than after, so a second caller arriving while this one is still
282
+ -- loading cannot see a count of zero and release the store out from under it.
283
+ self._handleCounts[userId] = (self._handleCounts[userId] or 0) + 1
284
+
285
+ return self:_promiseDataStoreByUserId(userId):Then(function(dataStore)
286
+ return PlayerDataStoreHandle.new(dataStore, function()
287
+ self:_releaseDataStoreHandle(userId)
288
+ end)
289
+ end, function(err)
290
+ -- The open failed, so there is no handle to be destroyed later. Give the count back.
291
+ self:_releaseDataStoreHandle(userId)
292
+ return Promise.rejected(err)
293
+ end)
294
+ end
295
+
296
+ function PlayerDataStoreManager._releaseDataStoreHandle(self: PlayerDataStoreManager, userId: PlayerUserId): ()
297
+ local count = self._handleCounts[userId]
298
+ if not count then
299
+ return
300
+ end
301
+
302
+ count -= 1
303
+ if count > 0 then
304
+ self._handleCounts[userId] = count
305
+ return
306
+ end
307
+
308
+ self._handleCounts[userId] = nil
309
+
310
+ -- A player in this server owns their own session through the join/leave path. Only a store opened
311
+ -- on behalf of someone absent is ours to close.
312
+ if Players:GetPlayerByUserId(userId) then
313
+ return
314
+ end
315
+
316
+ self:_removePlayerDataStore(userId)
317
+ end
318
+
319
+ --[=[
320
+ Resolves once any removal in flight for this player has saved and closed their session, and
321
+ immediately when there is nothing being removed.
322
+
323
+ Destroying the last handle for an absent player *starts* the save-and-close; it does not wait for
324
+ it. Tooling that reports back to an operator waits here first, so it says the lock is released
325
+ only once the write that releases it has actually landed.
326
+
327
+ @param playerOrUserId Player | number
328
+ @return Promise<()>
329
+ ]=]
330
+ function PlayerDataStoreManager.PromiseSessionClosed(
331
+ self: PlayerDataStoreManager,
332
+ playerOrUserId: Player | PlayerUserId
333
+ ): Promise.Promise<()>
334
+ local userId = self:_toPlayerUserIdOrError(playerOrUserId)
335
+
336
+ return self:_promiseWaitForRemoving(userId)
337
+ end
338
+
180
339
  --[=[
181
340
  Gets the datastore for a player. If it does not exist, it will create one.
182
341
 
@@ -279,6 +438,116 @@ function PlayerDataStoreManager:_toPlayerUserIdOrError(playerOrUserId: Player |
279
438
  ) :: PlayerUserId
280
439
  end
281
440
 
441
+ --[=[
442
+ Reads the session lock on a player's key without opening a session on it.
443
+
444
+ This is the read side of the tooling path: it answers "who holds this key, and how stale is that
445
+ claim", whether or not the player is in this server. Resolves nil when the key is unlocked or
446
+ absent. Reads the stored key, so for a player in this server it reflects their last save rather
447
+ than unsaved in-memory state.
448
+
449
+ @param playerOrUserId Player | number
450
+ @return Promise<LockData?>
451
+ ]=]
452
+ function PlayerDataStoreManager.PromiseReadSessionLock(
453
+ self: PlayerDataStoreManager,
454
+ playerOrUserId: Player | PlayerUserId
455
+ ): Promise.Promise<DataStoreLockUtils.LockData?>
456
+ local userId = self:_toPlayerUserIdOrError(playerOrUserId)
457
+
458
+ return DataStorePromises.getAsync(self._robloxDataStore, self:_getKey(userId)):Then(function(data)
459
+ return DataStoreLockUtils.readLock(data)
460
+ end)
461
+ end
462
+
463
+ --[=[
464
+ Clears the session lock on a player's key with a raw write, releasing a claim left behind by a
465
+ server that died without closing its session.
466
+
467
+ :::warning
468
+ This is a soft lock. A loading session steals it anyway once its retry ladder is exhausted (see
469
+ [PlayerDataStoreManager.SetLoadRetryOptions]) -- clearing it early only saves the player that wait.
470
+ :::
471
+
472
+ :::danger
473
+ Permitted against a session this server holds, which desynchronizes that session from the key --
474
+ its next save either re-writes the lock or reads this as a theft and kicks the player. That is a
475
+ debug/stress-test capability, not a normal one.
476
+ :::
477
+
478
+ @param playerOrUserId Player | number
479
+ @return Promise<LockData?> -- the lock that was cleared, or nil if it was already unlocked
480
+ ]=]
481
+ function PlayerDataStoreManager.PromiseUnlockSession(
482
+ self: PlayerDataStoreManager,
483
+ playerOrUserId: Player | PlayerUserId
484
+ ): Promise.Promise<DataStoreLockUtils.LockData?>
485
+ return self:_promiseWriteRawSessionLock(self:_toPlayerUserIdOrError(playerOrUserId), nil)
486
+ end
487
+
488
+ --[=[
489
+ Claims a player's key with a raw write, under a session this server will never answer for. Parks
490
+ the key so an inspection is not racing a live server.
491
+
492
+ :::warning
493
+ This is a soft lock, and holds only for as long as a loading session's retry ladder. It is not a
494
+ way to keep a player out of their data.
495
+ :::
496
+
497
+ :::danger
498
+ Permitted against a session this server holds, with the same desynchronizing effect described on
499
+ [PlayerDataStoreManager.PromiseUnlockSession].
500
+ :::
501
+
502
+ @param playerOrUserId Player | number
503
+ @return Promise<LockData?> -- the lock that was replaced, or nil if it was unlocked
504
+ ]=]
505
+ function PlayerDataStoreManager.PromiseLockSession(
506
+ self: PlayerDataStoreManager,
507
+ playerOrUserId: Player | PlayerUserId
508
+ ): Promise.Promise<DataStoreLockUtils.LockData?>
509
+ local userId = self:_toPlayerUserIdOrError(playerOrUserId)
510
+
511
+ return self:_promiseWriteRawSessionLock(
512
+ userId,
513
+ DataStoreLockUtils.createLockData({
514
+ SessionId = HttpService:GenerateGUID(false),
515
+ PlaceId = game.PlaceId,
516
+ JobId = game.JobId,
517
+ })
518
+ )
519
+ end
520
+
521
+ function PlayerDataStoreManager._promiseWriteRawSessionLock(
522
+ self: PlayerDataStoreManager,
523
+ userId: PlayerUserId,
524
+ lockData: DataStoreLockUtils.LockData?
525
+ ): Promise.Promise<DataStoreLockUtils.LockData?>
526
+ -- Deliberately unguarded against a session this server owns. A raw write underneath one
527
+ -- desynchronizes that session from the key: on its next save it either re-writes this lock, or
528
+ -- reads it as a theft and kicks the player. That is precisely the failure the lock/unlock tools
529
+ -- exist to provoke, so stress-testing against a live local session is allowed rather than refused.
530
+ -- Callers reaching for this outside of debug tooling want the live [DataStore] instead.
531
+ local previousLock: DataStoreLockUtils.LockData? = nil
532
+
533
+ return DataStorePromises.updateAsync(self._robloxDataStore, self:_getKey(userId), function(data, datastoreKeyInfo)
534
+ previousLock = DataStoreLockUtils.readLock(data)
535
+
536
+ -- Nothing stored and nothing to clear, so cancel rather than create an empty entry.
537
+ if data == nil and lockData == nil then
538
+ return nil
539
+ end
540
+
541
+ -- UpdateAsync drops both when the transform omits them, so carry them through untouched.
542
+ local userIdList = if datastoreKeyInfo then datastoreKeyInfo:GetUserIds() else { userId }
543
+ local metadata = if datastoreKeyInfo then datastoreKeyInfo:GetMetadata() else nil
544
+
545
+ return DataStoreLockUtils.withLock(data, lockData), userIdList, metadata
546
+ end):Then(function()
547
+ return previousLock
548
+ end)
549
+ end
550
+
282
551
  --[=[
283
552
  Removes all player data stores, and returns a promise that
284
553
  resolves when all pending saves are saved.
@@ -322,8 +591,21 @@ function PlayerDataStoreManager._createDataStore(
322
591
 
323
592
  local maid = Maid.new()
324
593
 
594
+ self._hasCreatedDataStore = true
595
+
325
596
  -- DataStore is cleaned up very carefully in _removePlayerDataStore
326
597
  local datastore = DataStore.new(self._robloxDataStore, self:_getKey(userId))
598
+
599
+ if self._loadRetryOptions then
600
+ datastore:SetLoadRetryOptions(self._loadRetryOptions)
601
+ end
602
+ if self._autoSaveTimeSecondsSet then
603
+ datastore:SetAutoSaveTimeSeconds(self._autoSaveTimeSeconds)
604
+ end
605
+ if self._sessionMessagingCloseDelaySeconds then
606
+ datastore:SetSessionMessagingCloseDelaySeconds(self._sessionMessagingCloseDelaySeconds)
607
+ end
608
+
327
609
  datastore:SetSessionLockingEnabled(true)
328
610
  datastore:SetSessionMessagingEnabled(true, self._serviceBag)
329
611
  datastore:SetUserIdList({ userId })
@@ -322,3 +322,89 @@ describe("PlayerDataStoreManager server shutdown", function()
322
322
  controller:destroy()
323
323
  end)
324
324
  end)
325
+
326
+ describe("PlayerDataStoreManager datastore configuration", function()
327
+ it("applies the configured autosave interval to created datastores", function()
328
+ local controller = DataStoreTestUtils.setupDataStoreManager()
329
+
330
+ controller.manager:SetAutoSaveTimeSeconds(14)
331
+
332
+ local dataStore = controller.manager:GetDataStore(1)
333
+ expect((dataStore:GetAutoSaveTimeSeconds())).toEqual(14)
334
+
335
+ controller:destroy()
336
+ end)
337
+
338
+ it("leaves the datastore default alone when unconfigured", function()
339
+ local controller = DataStoreTestUtils.setupDataStoreManager()
340
+
341
+ local dataStore = controller.manager:GetDataStore(1)
342
+ expect((dataStore:GetAutoSaveTimeSeconds())).toEqual(60 * 5)
343
+
344
+ controller:destroy()
345
+ end)
346
+
347
+ it("applies nil autosave (syncing disabled) rather than treating it as unconfigured", function()
348
+ local controller = DataStoreTestUtils.setupDataStoreManager()
349
+
350
+ controller.manager:SetAutoSaveTimeSeconds(nil)
351
+
352
+ local dataStore = controller.manager:GetDataStore(1)
353
+ expect(dataStore:GetAutoSaveTimeSeconds()).toBeNil()
354
+
355
+ controller:destroy()
356
+ end)
357
+
358
+ it("configures every datastore it creates, not just the first", function()
359
+ local controller = DataStoreTestUtils.setupDataStoreManager()
360
+
361
+ controller.manager:SetAutoSaveTimeSeconds(14)
362
+
363
+ controller.manager:GetDataStore(1)
364
+ local second = controller.manager:GetDataStore(2)
365
+ expect((second:GetAutoSaveTimeSeconds())).toEqual(14)
366
+
367
+ controller:destroy()
368
+ end)
369
+
370
+ it("rejects configuration once a datastore has been created", function()
371
+ local controller = DataStoreTestUtils.setupDataStoreManager()
372
+
373
+ controller.manager:GetDataStore(1)
374
+
375
+ expect(function()
376
+ controller.manager:SetAutoSaveTimeSeconds(14)
377
+ end).toThrow()
378
+ expect(function()
379
+ controller.manager:SetLoadRetryOptions({ initialWaitTime = 1, maxAttempts = 2, printWarning = false })
380
+ end).toThrow()
381
+ expect(function()
382
+ controller.manager:SetSessionMessagingCloseDelaySeconds(0.5)
383
+ end).toThrow()
384
+
385
+ controller:destroy()
386
+ end)
387
+
388
+ it("forwards load retry options to created datastores", function()
389
+ local controller = DataStoreTestUtils.setupDataStoreManager()
390
+
391
+ local retryOptions = { exponential = 1, initialWaitTime = 0.1, maxAttempts = 2, printWarning = false }
392
+ controller.manager:SetLoadRetryOptions(retryOptions)
393
+
394
+ local dataStore = controller.manager:GetDataStore(1)
395
+ expect((dataStore:GetLoadRetryOptions())).toEqual(retryOptions)
396
+
397
+ controller:destroy()
398
+ end)
399
+
400
+ it("forwards the session messaging close delay to created datastores", function()
401
+ local controller = DataStoreTestUtils.setupDataStoreManager()
402
+
403
+ controller.manager:SetSessionMessagingCloseDelaySeconds(0.5)
404
+
405
+ local dataStore = controller.manager:GetDataStore(1)
406
+ expect((dataStore:GetSessionMessagingCloseDelaySeconds())).toEqual(0.5)
407
+
408
+ controller:destroy()
409
+ end)
410
+ end)
@@ -11,8 +11,10 @@ local require = require(script.Parent.loader).load(script)
11
11
  local DataStore = require("DataStore")
12
12
  local DataStorePromises = require("DataStorePromises")
13
13
  local Maid = require("Maid")
14
+ local PlayerDataStoreHandle = require("PlayerDataStoreHandle")
14
15
  local PlayerDataStoreManager = require("PlayerDataStoreManager")
15
16
  local Promise = require("Promise")
17
+ local PromiseRetryUtils = require("PromiseRetryUtils")
16
18
  local ServiceBag = require("ServiceBag")
17
19
 
18
20
  local PlayerDataStoreService = {}
@@ -28,6 +30,10 @@ export type PlayerDataStoreService = typeof(setmetatable(
28
30
  _bindToCloseService: any,
29
31
  _promiseStarted: Promise.Promise<()>,
30
32
  _robloxDataStoreOverride: any?,
33
+ _loadRetryOptions: PromiseRetryUtils.RetryOptions?,
34
+ _autoSaveTimeSeconds: number?,
35
+ _autoSaveTimeSecondsSet: boolean,
36
+ _sessionMessagingCloseDelaySeconds: number?,
31
37
  },
32
38
  {} :: typeof({ __index = PlayerDataStoreService })
33
39
  ))
@@ -44,10 +50,14 @@ function PlayerDataStoreService.Init(self: PlayerDataStoreService, serviceBag: S
44
50
  self._bindToCloseService = self._serviceBag:GetService(require("BindToCloseService"))
45
51
  self._serviceBag:GetService(require("PlaceMessagingService"))
46
52
 
53
+ -- Internal
54
+ self._serviceBag:GetService(require("DataStoreCmdrService"))
55
+
47
56
  -- State
48
57
  self._promiseStarted = self._maid:Add(Promise.new())
49
58
  self._dataStoreName = "PlayerData"
50
59
  self._dataStoreScope = "SaveData"
60
+ self._autoSaveTimeSecondsSet = false
51
61
  end
52
62
 
53
63
  --[=[
@@ -92,6 +102,65 @@ function PlayerDataStoreService.SetDataStoreScope(self: PlayerDataStoreService,
92
102
  self._dataStoreScope = dataStoreScope
93
103
  end
94
104
 
105
+ --[=[
106
+ Overrides the load retry backoff on every player datastore. See
107
+ [PlayerDataStoreManager.SetLoadRetryOptions] -- this is what decides how long a player waits on a
108
+ lock held by a dead server before it is stolen.
109
+
110
+ :::info
111
+ Must be done before start and after init.
112
+ :::
113
+
114
+ @param options RetryOptions
115
+ ]=]
116
+ function PlayerDataStoreService.SetLoadRetryOptions(
117
+ self: PlayerDataStoreService,
118
+ options: PromiseRetryUtils.RetryOptions
119
+ ): ()
120
+ assert(type(options) == "table", "Bad options")
121
+ assert(self._promiseStarted, "Not initialized")
122
+ assert(self._promiseStarted:IsPending(), "Already started, cannot configure")
123
+
124
+ self._loadRetryOptions = options
125
+ end
126
+
127
+ --[=[
128
+ Sets the autosave interval on every player datastore. See
129
+ [PlayerDataStoreManager.SetAutoSaveTimeSeconds].
130
+
131
+ :::info
132
+ Must be done before start and after init.
133
+ :::
134
+
135
+ @param autoSaveTimeSeconds number?
136
+ ]=]
137
+ function PlayerDataStoreService.SetAutoSaveTimeSeconds(self: PlayerDataStoreService, autoSaveTimeSeconds: number?): ()
138
+ assert(type(autoSaveTimeSeconds) == "number" or autoSaveTimeSeconds == nil, "Bad autoSaveTimeSeconds")
139
+ assert(self._promiseStarted, "Not initialized")
140
+ assert(self._promiseStarted:IsPending(), "Already started, cannot configure")
141
+
142
+ self._autoSaveTimeSeconds = autoSaveTimeSeconds
143
+ self._autoSaveTimeSecondsSet = true
144
+ end
145
+
146
+ --[=[
147
+ Sets the post-graceful-close replication delay on every player datastore. See
148
+ [PlayerDataStoreManager.SetSessionMessagingCloseDelaySeconds].
149
+
150
+ :::info
151
+ Must be done before start and after init.
152
+ :::
153
+
154
+ @param seconds number
155
+ ]=]
156
+ function PlayerDataStoreService.SetSessionMessagingCloseDelaySeconds(self: PlayerDataStoreService, seconds: number): ()
157
+ assert(type(seconds) == "number" and seconds >= 0, "Bad seconds")
158
+ assert(self._promiseStarted, "Not initialized")
159
+ assert(self._promiseStarted:IsPending(), "Already started, cannot configure")
160
+
161
+ self._sessionMessagingCloseDelaySeconds = seconds
162
+ end
163
+
95
164
  --[=[
96
165
  Injects the underlying datastore the manager wraps, instead of resolving a real one. Accepts
97
166
  a real datastore or a [DataStoreMock]. Intended for testing; must be called before the manager
@@ -126,6 +195,39 @@ function PlayerDataStoreService.PromiseDataStore(
126
195
  end)
127
196
  end
128
197
 
198
+ --[=[
199
+ Borrows the player's [DataStore] as a [PlayerDataStoreHandle], which releases the session when
200
+ destroyed. Prefer this over [PlayerDataStoreService.PromiseDataStore] when acting on a player by
201
+ userId, since it is what makes the release hard to forget -- see [PlayerDataStoreHandle].
202
+
203
+ @param playerOrUserId Player | number
204
+ @return Promise<PlayerDataStoreHandle>
205
+ ]=]
206
+ function PlayerDataStoreService.PromiseDataStoreHandle(
207
+ self: PlayerDataStoreService,
208
+ playerOrUserId: Player | number
209
+ ): Promise.Promise<PlayerDataStoreHandle.PlayerDataStoreHandle>
210
+ return self:PromiseManager():Then(function(manager)
211
+ return manager:PromiseDataStoreHandle(playerOrUserId)
212
+ end)
213
+ end
214
+
215
+ --[=[
216
+ Resolves once any removal in flight for this player has saved and closed their session -- see
217
+ [PlayerDataStoreManager.PromiseSessionClosed].
218
+
219
+ @param playerOrUserId Player | number
220
+ @return Promise<()>
221
+ ]=]
222
+ function PlayerDataStoreService.PromiseSessionClosed(
223
+ self: PlayerDataStoreService,
224
+ playerOrUserId: Player | number
225
+ ): Promise.Promise<()>
226
+ return self:PromiseManager():Then(function(manager)
227
+ return manager:PromiseSessionClosed(playerOrUserId)
228
+ end)
229
+ end
230
+
129
231
  --[=[
130
232
  Adds a removing callback to the manager.
131
233
  @param callback function -- May return a promise
@@ -167,6 +269,16 @@ function PlayerDataStoreService.PromiseManager(
167
269
  end
168
270
  end, true))
169
271
 
272
+ if self._loadRetryOptions then
273
+ manager:SetLoadRetryOptions(self._loadRetryOptions)
274
+ end
275
+ if self._autoSaveTimeSecondsSet then
276
+ manager:SetAutoSaveTimeSeconds(self._autoSaveTimeSeconds)
277
+ end
278
+ if self._sessionMessagingCloseDelaySeconds then
279
+ manager:SetSessionMessagingCloseDelaySeconds(self._sessionMessagingCloseDelaySeconds)
280
+ end
281
+
170
282
  -- A lot safer if we're hot reloading or need to monitor bind to close calls
171
283
  self._maid:GiveTask(self._bindToCloseService:RegisterPromiseOnCloseCallback(function()
172
284
  return manager:PromiseAllSaves()
@@ -211,3 +211,68 @@ describe("PlayerDataStoreService server shutdown", function()
211
211
  controller:destroy()
212
212
  end)
213
213
  end)
214
+
215
+ describe("PlayerDataStoreService datastore configuration", function()
216
+ -- The shared setup() starts the bag as soon as it has a mock, and these setters must land between
217
+ -- Init and Start -- the same window SetDataStoreName uses.
218
+ local function setupConfigured(configure)
219
+ local maid = Maid.new()
220
+
221
+ local mock = DataStoreMock.new()
222
+ local serviceBag = maid:Add(ServiceBag.new())
223
+ local service = serviceBag:GetService(require("PlayerDataStoreService"))
224
+ serviceBag:Init()
225
+
226
+ service:SetRobloxDataStore(mock)
227
+ configure(service)
228
+ serviceBag:Start()
229
+
230
+ return {
231
+ service = service,
232
+ mock = mock,
233
+ destroy = function()
234
+ DataStoreTestUtils.awaitServiceShutdown(service)
235
+ maid:DoCleaning()
236
+ end,
237
+ }
238
+ end
239
+
240
+ it("forwards configuration through to the datastores the manager creates", function()
241
+ local retryOptions = { exponential = 1, initialWaitTime = 0.1, maxAttempts = 2, printWarning = false }
242
+ local controller = setupConfigured(function(service)
243
+ service:SetAutoSaveTimeSeconds(14)
244
+ service:SetLoadRetryOptions(retryOptions)
245
+ service:SetSessionMessagingCloseDelaySeconds(0.5)
246
+ end)
247
+
248
+ local promise = controller.service:PromiseDataStore(1)
249
+ if not PromiseTestUtils.awaitSettled(promise, 10) then
250
+ expect("hung").toEqual("settled")
251
+ controller:destroy()
252
+ return
253
+ end
254
+ local _ok, dataStore = promise:Yield()
255
+
256
+ expect((dataStore:GetAutoSaveTimeSeconds())).toEqual(14)
257
+ expect((dataStore:GetLoadRetryOptions())).toEqual(retryOptions)
258
+ expect((dataStore:GetSessionMessagingCloseDelaySeconds())).toEqual(0.5)
259
+
260
+ controller:destroy()
261
+ end)
262
+
263
+ it("rejects configuration after start", function()
264
+ local controller = setupConfigured(function() end)
265
+
266
+ expect(function()
267
+ controller.service:SetAutoSaveTimeSeconds(14)
268
+ end).toThrow()
269
+ expect(function()
270
+ controller.service:SetLoadRetryOptions({ initialWaitTime = 1, maxAttempts = 2, printWarning = false })
271
+ end).toThrow()
272
+ expect(function()
273
+ controller.service:SetSessionMessagingCloseDelaySeconds(0.5)
274
+ end).toThrow()
275
+
276
+ controller:destroy()
277
+ end)
278
+ end)