@quenty/datastore 13.51.0 → 13.52.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.
@@ -8,6 +8,7 @@ local DataStoreTestUtils = require("DataStoreTestUtils")
8
8
  local Jest = require("Jest")
9
9
  local PlayerMock = require("PlayerMock")
10
10
  local PromiseTestUtils = require("PromiseTestUtils")
11
+ local PromiseUtils = require("PromiseUtils")
11
12
 
12
13
  local describe = Jest.Globals.describe
13
14
  local expect = Jest.Globals.expect
@@ -189,94 +190,115 @@ describe("PlayerDataStoreManager.PromiseAllSaves", function()
189
190
  end)
190
191
  end)
191
192
 
192
- describe("PlayerDataStoreManager teardown", function()
193
- it("destroys the datastores it still owns when the manager is destroyed", function()
193
+ -- Models a closing server the way Roblox actually behaves: PlayerRemoving fires for everyone still in
194
+ -- the server and does the save-and-close, and the close is held open until those removals flush.
195
+ describe("PlayerDataStoreManager server shutdown", function()
196
+ it("saves the staged data and releases the lock for the leaving player", function()
194
197
  local controller = DataStoreTestUtils.setupDataStoreManager()
195
198
 
196
- local dataStore = controller.manager:GetDataStore(1)
197
- if not expectSettled(dataStore:PromiseLoadSuccessful(), 10) then
199
+ if not controller.storeAndAwaitLock() then
200
+ expect("lock was never acquired").toEqual("lock was acquired")
198
201
  controller:destroy()
199
202
  return
200
203
  end
201
204
 
202
- controller.manager:Destroy()
205
+ if not expectSettled(controller.promiseShutdown({ 1 }), 10) then
206
+ controller:destroy()
207
+ return
208
+ end
203
209
 
204
- expect(getmetatable(dataStore)).toBeNil()
210
+ local raw = controller.mock:GetRaw("user_1")
211
+ expect(raw.coins).toEqual(5)
212
+ expect(raw.lock).toEqual(nil)
205
213
 
206
214
  controller:destroy()
207
215
  end)
208
216
 
209
- it("flushes staged data synchronously to the underlying store when destroyed", function()
217
+ it("releases the lock for every player still in the server", function()
210
218
  local controller = DataStoreTestUtils.setupDataStoreManager()
211
219
 
212
- local dataStore = controller.manager:GetDataStore(1)
213
- if not expectSettled(dataStore:PromiseLoadSuccessful(), 10) then
220
+ controller.manager:GetDataStore(1):Store("coins", 1)
221
+ controller.manager:GetDataStore(2):Store("coins", 2)
222
+
223
+ local locked = PromiseTestUtils.awaitValue(function()
224
+ local rawOne = controller.mock:GetRaw("user_1")
225
+ local rawTwo = controller.mock:GetRaw("user_2")
226
+ return rawOne ~= nil and rawOne.lock ~= nil and rawTwo ~= nil and rawTwo.lock ~= nil
227
+ end, 10)
228
+ if not locked then
229
+ expect("both locks were never acquired").toEqual("both locks were acquired")
214
230
  controller:destroy()
215
231
  return
216
232
  end
217
233
 
218
- dataStore:Store("coins", 5)
219
-
220
- controller.manager:Destroy()
234
+ if not expectSettled(controller.promiseShutdown({ 1, 2 }), 10) then
235
+ controller:destroy()
236
+ return
237
+ end
221
238
 
222
- local raw = controller.mock:GetRaw("user_1")
223
- expect(raw).never.toBeNil()
224
- expect(raw.coins).toEqual(5)
239
+ expect(controller.mock:GetRaw("user_1").lock).toEqual(nil)
240
+ expect(controller.mock:GetRaw("user_2").lock).toEqual(nil)
241
+ expect(controller.mock:GetRaw("user_1").coins).toEqual(1)
242
+ expect(controller.mock:GetRaw("user_2").coins).toEqual(2)
225
243
 
226
244
  controller:destroy()
227
245
  end)
228
246
 
229
- it("releases the session lock when destroyed, not just the staged data", function()
247
+ it("destroys each store once its removal has flushed", function()
230
248
  local controller = DataStoreTestUtils.setupDataStoreManager()
231
249
 
232
- if not controller.storeAndAwaitLock() then
233
- expect("lock was never acquired").toEqual("lock was acquired")
250
+ local dataStore = controller.manager:GetDataStore(1)
251
+ if not expectSettled(dataStore:PromiseLoadSuccessful(), 10) then
234
252
  controller:destroy()
235
253
  return
236
254
  end
237
255
 
238
- controller.manager:Destroy()
256
+ if not expectSettled(controller.promiseShutdown({ 1 }), 10) then
257
+ controller:destroy()
258
+ return
259
+ end
239
260
 
240
- expect(PromiseTestUtils.awaitValue(function()
241
- local raw = controller.mock:GetRaw("user_1")
242
- return raw ~= nil and raw.lock == nil
243
- end, 5)).toEqual(true)
244
- expect(controller.mock:GetRaw("user_1").coins).toEqual(5)
261
+ expect(getmetatable(dataStore)).toBeNil()
245
262
 
246
263
  controller:destroy()
247
264
  end)
248
265
 
249
- it("releases the lock for every store it still owns", function()
266
+ it("does not resolve the close while a PlayerRemoving save is still in flight", function()
250
267
  local controller = DataStoreTestUtils.setupDataStoreManager()
251
268
 
252
- controller.manager:GetDataStore(1):Store("coins", 1)
253
- controller.manager:GetDataStore(2):Store("coins", 2)
269
+ -- An async removing callback pushes the write past the moment the close is requested, which is
270
+ -- exactly the window where waiting on pending saves alone finds nothing to wait for.
271
+ controller.manager:AddRemovingCallback(function()
272
+ return PromiseUtils.delayed(0.5)
273
+ end)
254
274
 
255
- local locked = PromiseTestUtils.awaitValue(function()
256
- local rawOne = controller.mock:GetRaw("user_1")
257
- local rawTwo = controller.mock:GetRaw("user_2")
258
- return rawOne ~= nil and rawOne.lock ~= nil and rawTwo ~= nil and rawTwo.lock ~= nil
259
- end, 10)
260
- if not locked then
261
- expect("both locks were never acquired").toEqual("both locks were acquired")
275
+ if not controller.storeAndAwaitLock() then
276
+ expect("lock was never acquired").toEqual("lock was acquired")
262
277
  controller:destroy()
263
278
  return
264
279
  end
265
280
 
266
- controller.manager:Destroy()
281
+ -- PlayerRemoving lands first, then the server begins closing.
282
+ controller.manager:RemovePlayerDataStore(1)
267
283
 
268
- expect(PromiseTestUtils.awaitValue(function()
269
- local rawOne = controller.mock:GetRaw("user_1")
270
- local rawTwo = controller.mock:GetRaw("user_2")
271
- return rawOne ~= nil and rawOne.lock == nil and rawTwo ~= nil and rawTwo.lock == nil
272
- end, 5)).toEqual(true)
273
- expect(controller.mock:GetRaw("user_1").coins).toEqual(1)
274
- expect(controller.mock:GetRaw("user_2").coins).toEqual(2)
284
+ local closePromise = controller.manager:PromiseAllSaves()
285
+ expect(closePromise:IsPending()).toEqual(true)
286
+
287
+ if not expectSettled(closePromise, 10) then
288
+ controller:destroy()
289
+ return
290
+ end
291
+
292
+ -- The close resolving has to mean the write landed. If it can resolve first, the real server
293
+ -- dies here with the session still locked and no other server able to release it.
294
+ local raw = controller.mock:GetRaw("user_1")
295
+ expect(raw.coins).toEqual(5)
296
+ expect(raw.lock).toEqual(nil)
275
297
 
276
298
  controller:destroy()
277
299
  end)
278
300
 
279
- it("tears down cleanly when a store's load failed, leaking no rejection and writing no lock", function()
301
+ it("closes cleanly when a store's load failed, leaking no rejection and writing no lock", function()
280
302
  local controller = DataStoreTestUtils.setupDataStoreManager()
281
303
 
282
304
  controller.mock:FailAllRequests()
@@ -290,10 +312,99 @@ describe("PlayerDataStoreManager teardown", function()
290
312
  end
291
313
  expect((loaded:Wait())).toEqual(false)
292
314
 
293
- controller.manager:Destroy()
315
+ if not expectSettled(controller.promiseShutdown({ 1 }), 10) then
316
+ controller:destroy()
317
+ return
318
+ end
294
319
 
295
320
  expect(controller.mock:GetRaw("user_1")).toBeNil()
296
321
 
297
322
  controller:destroy()
298
323
  end)
299
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()
@@ -5,8 +5,10 @@
5
5
  local require = require(script.Parent.loader).load(script)
6
6
 
7
7
  local DataStoreMock = require("DataStoreMock")
8
+ local DataStoreTestUtils = require("DataStoreTestUtils")
8
9
  local Jest = require("Jest")
9
10
  local Maid = require("Maid")
11
+ local Promise = require("Promise")
10
12
  local PromiseTestUtils = require("PromiseTestUtils")
11
13
  local ServiceBag = require("ServiceBag")
12
14
 
@@ -26,10 +28,26 @@ local function setup(mock)
26
28
  serviceBag:Start()
27
29
  end
28
30
 
31
+ -- Drives the real close path: the manager the service owns, shut down the way Roblox does. Without a
32
+ -- mock the bag was never started, so there is no manager to reach and nothing to shut down.
33
+ local function promiseShutdown(userIds)
34
+ if not mock then
35
+ return Promise.resolved()
36
+ end
37
+
38
+ return service:PromiseManager():Then(function(manager)
39
+ return DataStoreTestUtils.promiseSimulatedShutdown(manager, userIds)
40
+ end)
41
+ end
42
+
29
43
  return {
30
44
  service = service,
31
45
  mock = mock,
46
+ promiseShutdown = promiseShutdown,
32
47
  destroy = function()
48
+ -- Otherwise a store the spec loaded outlives it with its auto-save loop running, and fires
49
+ -- inside a later package's window in the shared test place.
50
+ PromiseTestUtils.awaitSettled(promiseShutdown(), 5)
33
51
  maid:DoCleaning()
34
52
  end,
35
53
  }
@@ -157,8 +175,10 @@ describe("PlayerDataStoreService failure handling", function()
157
175
  end)
158
176
  end)
159
177
 
160
- describe("PlayerDataStoreService teardown", function()
161
- it("destroys the datastore its manager owns when the service is destroyed", function()
178
+ -- The service registers manager:PromiseAllSaves() as its BindToClose callback, so a closing server is
179
+ -- PlayerRemoving doing the save-and-close with that callback held open until it flushes.
180
+ describe("PlayerDataStoreService server shutdown", function()
181
+ it("saves the staged data and destroys the store when the server closes", function()
162
182
  local controller = setup(DataStoreMock.new())
163
183
 
164
184
  local promise = controller.service:PromiseDataStore(1)
@@ -175,13 +195,55 @@ describe("PlayerDataStoreService teardown", function()
175
195
  return
176
196
  end
177
197
 
178
- controller:destroy()
198
+ dataStore:Store("coins", 7)
179
199
 
200
+ if not PromiseTestUtils.awaitSettled(controller.promiseShutdown({ 1 }), 10) then
201
+ expect("shutdown never flushed").toEqual("shutdown flushed")
202
+ controller:destroy()
203
+ return
204
+ end
205
+
206
+ local raw = controller.mock:GetRaw("1")
207
+ expect(raw).never.toBeNil()
208
+ expect(raw.coins).toEqual(7)
180
209
  expect(getmetatable(dataStore)).toBeNil()
210
+
211
+ controller:destroy()
181
212
  end)
213
+ end)
182
214
 
183
- it("flushes staged data synchronously to the underlying store when destroyed", function()
184
- local controller = setup(DataStoreMock.new())
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)
185
247
 
186
248
  local promise = controller.service:PromiseDataStore(1)
187
249
  if not PromiseTestUtils.awaitSettled(promise, 10) then
@@ -191,18 +253,26 @@ describe("PlayerDataStoreService teardown", function()
191
253
  end
192
254
  local _ok, dataStore = promise:Yield()
193
255
 
194
- if not PromiseTestUtils.awaitSettled(dataStore:PromiseLoadSuccessful(), 10) then
195
- expect("load hung").toEqual("load settled")
196
- controller:destroy()
197
- return
198
- end
199
-
200
- dataStore:Store("coins", 7)
256
+ expect((dataStore:GetAutoSaveTimeSeconds())).toEqual(14)
257
+ expect((dataStore:GetLoadRetryOptions())).toEqual(retryOptions)
258
+ expect((dataStore:GetSessionMessagingCloseDelaySeconds())).toEqual(0.5)
201
259
 
202
260
  controller:destroy()
261
+ end)
203
262
 
204
- local raw = controller.mock:GetRaw("1")
205
- expect(raw).never.toBeNil()
206
- expect(raw.coins).toEqual(7)
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()
207
277
  end)
208
278
  end)
@@ -0,0 +1,57 @@
1
+ --!strict
2
+ --[=[
3
+ Shared plumbing for the datastore Cmdr commands. Runs on both realms, so nothing here may reach
4
+ for a store -- those live on the server.
5
+
6
+ @class DataStoreCmdrUtils
7
+ ]=]
8
+
9
+ local DataStoreCmdrUtils = {}
10
+
11
+ --[=[
12
+ Splits a slash-delimited sub-store path into its segments, dropping empty ones so a stray or
13
+ trailing slash is forgiving rather than an error. An empty path means the root store.
14
+
15
+ @param text string
16
+ @return { string }
17
+ ]=]
18
+ function DataStoreCmdrUtils.parsePath(text: string): { string }
19
+ assert(type(text) == "string", "Bad text")
20
+
21
+ local path = {}
22
+ for segment in string.gmatch(text, "[^/]+") do
23
+ table.insert(path, segment)
24
+ end
25
+
26
+ return path
27
+ end
28
+
29
+ --[=[
30
+ Registers the sub-store path type.
31
+
32
+ Cmdr resolves types against the executor, who cannot see another player's stores, so a path is
33
+ accepted as typed rather than completed against anything.
34
+
35
+ @param cmdr Cmdr
36
+ ]=]
37
+ function DataStoreCmdrUtils.registerSubStoreType(cmdr: any): ()
38
+ local subStore = {
39
+ Transform = function(text: string)
40
+ if text ~= "" then
41
+ return { text }
42
+ end
43
+
44
+ return {}
45
+ end,
46
+ Validate = function(keys: { string })
47
+ return #keys > 0, "Not a sub-store path."
48
+ end,
49
+ Parse = function(keys: { string })
50
+ return DataStoreCmdrUtils.parsePath(keys[1])
51
+ end,
52
+ }
53
+
54
+ cmdr.Registry:RegisterType("dataStoreSubStore", subStore)
55
+ end
56
+
57
+ return DataStoreCmdrUtils