@quenty/datastore 13.40.0 → 13.42.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 (33) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/package.json +12 -12
  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/Shared/Utility/DataStoreStringUtils.spec.lua +1 -1
  33. package/src/jest.config.lua +3 -0
@@ -0,0 +1,190 @@
1
+ --!nonstrict
2
+ --[[
3
+ Two servers fighting over one player's session-locked key: two DataStore objects (server A and
4
+ server B) share one DataStoreMock with distinct SessionIds, exactly like two live game servers.
5
+ This exercises the full cross-server lock lifecycle -- clean handoff via close, crash recovery via
6
+ stale-lock steal, a concurrent-load race, the MessagingService graceful-close handshake, and
7
+ session-stolen data integrity.
8
+
9
+ @class DataStoreTwoServerLock.spec.lua
10
+ ]]
11
+ local require = require(script.Parent.loader).load(script)
12
+
13
+ local DataStoreTestUtils = require("DataStoreTestUtils")
14
+ local Jest = require("Jest")
15
+ local PromiseTestUtils = require("PromiseTestUtils")
16
+
17
+ local describe = Jest.Globals.describe
18
+ local expect = Jest.Globals.expect
19
+ local it = Jest.Globals.it
20
+
21
+ local KEY = "player_1"
22
+
23
+ describe("two servers: clean handoff and crash recovery", function()
24
+ it("hands a player off cleanly: A saves and closes, B loads A's data and owns the lock", function()
25
+ local controller = DataStoreTestUtils.setup()
26
+
27
+ local serverA = controller.newServer()
28
+ expect(controller.awaitOwn(serverA)).toEqual(true)
29
+ serverA:Store("coins", 42)
30
+ if not PromiseTestUtils.awaitSettled(serverA:SaveAndCloseSession(), 10) then
31
+ expect("A close hung").toEqual("settled")
32
+ controller:destroy()
33
+ return
34
+ end
35
+
36
+ local serverB = controller.newServer()
37
+ local coins = serverB:Load("coins")
38
+ if not PromiseTestUtils.awaitSettled(coins, 10) then
39
+ expect("B load hung").toEqual("settled")
40
+ controller:destroy()
41
+ return
42
+ end
43
+ expect((coins:Wait())).toEqual(42)
44
+ expect(controller.mock:GetRaw(KEY).lock.ActiveSession.SessionId).toEqual(serverB:GetSessionId())
45
+
46
+ controller:destroy()
47
+ end)
48
+
49
+ it("recovers a crashed server's saved data by stealing its stale lock", function()
50
+ local controller = DataStoreTestUtils.setup()
51
+
52
+ -- A acquires, saves coins under its lock, then "crashes" (no clean close).
53
+ local serverA = controller.newServer()
54
+ expect(controller.awaitOwn(serverA)).toEqual(true)
55
+ serverA:Store("coins", 7)
56
+ if not PromiseTestUtils.awaitSettled(serverA:Save(), 10) then
57
+ expect("A save hung").toEqual("settled")
58
+ controller:destroy()
59
+ return
60
+ end
61
+
62
+ -- Age A's lock so it looks like a long-dead (crashed) server, then abandon A.
63
+ local raw = controller.mock:GetRaw(KEY)
64
+ raw.lock.LastUpdateTime = os.time() - 1000000
65
+ controller.mock:SetRaw(KEY, raw)
66
+
67
+ -- B loads: steals the stale lock and recovers A's saved coins.
68
+ local serverB = controller.newServer()
69
+ local coins = serverB:Load("coins")
70
+ if not PromiseTestUtils.awaitSettled(coins, 10) then
71
+ expect("B load hung").toEqual("settled")
72
+ controller:destroy()
73
+ return
74
+ end
75
+ expect((coins:Wait())).toEqual(7)
76
+ expect(controller.mock:GetRaw(KEY).lock.ActiveSession.SessionId).toEqual(serverB:GetSessionId())
77
+
78
+ controller:destroy()
79
+ end)
80
+
81
+ it("lets exactly one of two concurrent loads acquire the lock; the other stays blocked", function()
82
+ local controller = DataStoreTestUtils.setup()
83
+
84
+ local serverA = controller.newServer()
85
+ local serverB = controller.newServer()
86
+
87
+ local loadA = serverA:PromiseLoadSuccessful()
88
+ local loadB = serverB:PromiseLoadSuccessful()
89
+
90
+ -- One acquires quickly; the other is blocked (retrying against the winner's fresh lock).
91
+ expect(PromiseTestUtils.awaitValue(function()
92
+ return not loadA:IsPending() or not loadB:IsPending()
93
+ end, 5)).toEqual(true)
94
+
95
+ local aSettled = not loadA:IsPending()
96
+ local bSettled = not loadB:IsPending()
97
+ expect(aSettled ~= bSettled).toEqual(true) -- exactly one
98
+
99
+ local owner = controller.mock:GetRaw(KEY).lock.ActiveSession.SessionId
100
+ expect(owner == serverA:GetSessionId() or owner == serverB:GetSessionId()).toEqual(true)
101
+
102
+ controller:destroy()
103
+ end)
104
+
105
+ it("cancels the loser's write when a session is stolen (no duplication)", function()
106
+ local controller = DataStoreTestUtils.setup()
107
+
108
+ local serverA = controller.newServer()
109
+ expect(controller.awaitOwn(serverA)).toEqual(true)
110
+
111
+ -- B takes over by writing its own lock + data directly (as if it stole the session).
112
+ controller.mock:SetRaw(KEY, {
113
+ coins = 100,
114
+ lock = {
115
+ LastUpdateTime = os.time(),
116
+ ActiveSession = { SessionId = "server-b", PlaceId = 222, JobId = "job-b" },
117
+ },
118
+ })
119
+
120
+ -- A tries to save its own value; the write is cancelled (SessionStolen), B's data survives.
121
+ local stolen = nil
122
+ serverA.SessionStolen:Connect(function(session)
123
+ stolen = session
124
+ end)
125
+ serverA:Store("coins", 5)
126
+ if not PromiseTestUtils.awaitSettled(serverA:Save(), 10) then
127
+ expect("A save hung").toEqual("settled")
128
+ controller:destroy()
129
+ return
130
+ end
131
+
132
+ expect(stolen).never.toBeNil()
133
+ expect(controller.mock:GetRaw(KEY).coins).toEqual(100)
134
+
135
+ controller:destroy()
136
+ end)
137
+ end)
138
+
139
+ describe("two servers: MessagingService graceful close", function()
140
+ it("completes the close handshake: B asks A to close, A closes and releases the lock", function()
141
+ local controller = DataStoreTestUtils.setup()
142
+
143
+ local serverA = controller.newServer({ messaging = true, autoCloseOnRequest = true })
144
+ expect(controller.awaitOwn(serverA)).toEqual(true)
145
+
146
+ local _serverB, helperB = controller.newServer({ messaging = true })
147
+
148
+ -- B gracefully asks A (the lock holder) to close.
149
+ local graceful = helperB:PromiseCloseSessionGraceful(game.PlaceId, game.JobId, serverA:GetSessionId())
150
+ if not PromiseTestUtils.awaitSettled(graceful, 15) then
151
+ expect("graceful close hung").toEqual("resolved")
152
+ controller:destroy()
153
+ return
154
+ end
155
+ expect((graceful:Yield())).toEqual(true)
156
+
157
+ -- A honored the request and released its lock.
158
+ expect(PromiseTestUtils.awaitValue(function()
159
+ local raw = controller.mock:GetRaw(KEY)
160
+ return raw ~= nil and raw.lock == nil
161
+ end, 5)).toEqual(true)
162
+
163
+ controller:destroy()
164
+ end, 30000) -- MessagingService round-trip, beyond jest's 5s default
165
+
166
+ it("evicts the holder during a messaging-enabled load and then acquires (production flow)", function()
167
+ local controller = DataStoreTestUtils.setup()
168
+
169
+ -- A holds the lock and will honor a graceful close request.
170
+ local serverA = controller.newServer({ messaging = true, autoCloseOnRequest = true })
171
+ expect(controller.awaitOwn(serverA)).toEqual(true)
172
+
173
+ -- B loads with messaging enabled: blocked by A's fresh lock, its load asks A to close, then
174
+ -- (after the propagation delay) retries and acquires. This is the real cross-server handoff.
175
+ -- Use a tiny propagation delay so the test does not wait the production 5s.
176
+ local serverB = controller.newServer({ messaging = true })
177
+ serverB:SetSessionMessagingCloseDelaySeconds(0.1)
178
+ local loadB = serverB:PromiseLoadSuccessful()
179
+
180
+ if not PromiseTestUtils.awaitSettled(loadB, 8) then
181
+ expect("B messaging load hung").toEqual("settled")
182
+ controller:destroy()
183
+ return
184
+ end
185
+ expect((loadB:Wait())).toEqual(true)
186
+ expect(controller.mock:GetRaw(KEY).lock.ActiveSession.SessionId).toEqual(serverB:GetSessionId())
187
+
188
+ controller:destroy()
189
+ end)
190
+ end)
@@ -72,6 +72,7 @@ local HttpService = game:GetService("HttpService")
72
72
  local DataStoreDeleteToken = require("DataStoreDeleteToken")
73
73
  local DataStoreLockHelper = require("DataStoreLockHelper")
74
74
  local DataStoreMessageHelper = require("DataStoreMessageHelper")
75
+ local DataStoreNonRetryableLoadError = require("DataStoreNonRetryableLoadError")
75
76
  local DataStorePromises = require("DataStorePromises")
76
77
  local DataStoreStage = require("DataStoreStage")
77
78
  local Maid = require("Maid")
@@ -92,6 +93,18 @@ local DEFAULT_DEBUG_WRITING = false
92
93
  local DEFAULT_AUTO_SAVE_TIME_SECONDS = 60 * 5
93
94
  local DEFAULT_JITTER_PROPORTION = 0.1 -- Randomly assign jitter so if a ton of players join at once we don't hit the datastore at once
94
95
 
96
+ -- Retry backoff for the session-locked load (~49s total); tunable so tests need not wait it out.
97
+ -- https://exponentialbackoffcalculator.com/
98
+ local DEFAULT_LOAD_RETRY_OPTIONS = {
99
+ exponential = 1.25,
100
+ initialWaitTime = 6,
101
+ maxAttempts = 6,
102
+ printWarning = true,
103
+ }
104
+
105
+ -- After a graceful session-close request we wait for Roblox to replicate the release before retrying.
106
+ local DEFAULT_SESSION_MESSAGING_CLOSE_DELAY_SECONDS = 5
107
+
95
108
  local DataStore = setmetatable({}, DataStoreStage)
96
109
  DataStore.ClassName = "DataStore"
97
110
  DataStore.__index = DataStore
@@ -113,6 +126,8 @@ export type DataStore =
113
126
  _loadedOk: ValueObject.ValueObject<boolean>,
114
127
  _firstLoadPromise: Promise.Promise<()>,
115
128
  _promiseSessionLockingFailed: Promise.Promise<()>,
129
+ _loadRetryOptions: PromiseRetryUtils.RetryOptions,
130
+ _sessionMessagingCloseDelaySeconds: number,
116
131
 
117
132
  -- Events
118
133
  Saving: Signal.Signal<Promise.Promise<()>>,
@@ -147,6 +162,8 @@ function DataStore.new(robloxDataStore: DataStorePromises.RobloxDataStore, key:
147
162
  self._syncOnSave = self._maid:Add(ValueObject.new(false, "boolean"))
148
163
  self._loadedOk = self._maid:Add(ValueObject.new(false, "boolean"))
149
164
  self._promiseSessionLockingFailed = self._maid:Add(Promise.new())
165
+ self._loadRetryOptions = DEFAULT_LOAD_RETRY_OPTIONS
166
+ self._sessionMessagingCloseDelaySeconds = DEFAULT_SESSION_MESSAGING_CLOSE_DELAY_SECONDS
150
167
 
151
168
  self._userIdList = nil
152
169
 
@@ -314,6 +331,32 @@ function DataStore.SetSyncOnSave(self: DataStore, syncEnabled: boolean)
314
331
  self._syncOnSave.Value = syncEnabled
315
332
  end
316
333
 
334
+ --[=[
335
+ Overrides the retry backoff used while acquiring a session-locked load. Defaults to ~49s of
336
+ exponential backoff. Mainly useful for tests, which set a tiny backoff so they need not wait it
337
+ out. Must be set before the datastore loads.
338
+
339
+ @param options { exponential: number?, initialWaitTime: number, maxAttempts: number, printWarning: boolean }
340
+ ]=]
341
+ function DataStore.SetLoadRetryOptions(self: DataStore, options: PromiseRetryUtils.RetryOptions): ()
342
+ assert(not self._firstLoadPromise, "Must set load retry options before the datastore is loaded")
343
+ assert(type(options) == "table", "Bad options")
344
+
345
+ self._loadRetryOptions = options
346
+ end
347
+
348
+ --[=[
349
+ Overrides how long to wait for Roblox to replicate a released lock after a graceful session-close
350
+ request before retrying the load. Defaults to 5 seconds. Mainly useful for tests.
351
+
352
+ @param seconds number
353
+ ]=]
354
+ function DataStore.SetSessionMessagingCloseDelaySeconds(self: DataStore, seconds: number): ()
355
+ assert(type(seconds) == "number" and seconds >= 0, "Bad seconds")
356
+
357
+ self._sessionMessagingCloseDelaySeconds = seconds
358
+ end
359
+
317
360
  --[=[
318
361
  Returns whether the datastore failed.
319
362
  @return boolean
@@ -405,9 +448,13 @@ function DataStore.PromiseViewUpToDate(self: DataStore): Promise.Promise<()>
405
448
  local promise = self:_promiseGetAsyncNoCache()
406
449
  self._firstLoadPromise = promise
407
450
 
408
- promise:Tap(function()
409
- self._loadedOk.Value = true
410
- end)
451
+ -- Fire-and-forget side effect; Catch so a failed/cancelled load is not surfaced as an uncaught
452
+ -- rejection on this discarded branch (the load result itself is handled by callers).
453
+ promise
454
+ :Tap(function()
455
+ self._loadedOk.Value = true
456
+ end)
457
+ :Catch(function() end)
411
458
 
412
459
  return promise
413
460
  end
@@ -599,10 +646,12 @@ function DataStore._doDataSync(
599
646
  )
600
647
  end
601
648
 
602
- promise:Tap(nil, function(err)
603
- -- Might be caused by Maid rejecting state
604
- warn("[DataStore] - Failed to sync data", err)
605
- end)
649
+ promise
650
+ :Tap(nil, function(err)
651
+ -- Might be caused by Maid rejecting state
652
+ warn("[DataStore] - Failed to sync data", err)
653
+ end)
654
+ :Catch(function() end)
606
655
 
607
656
  self._maid._saveMaid = maid
608
657
 
@@ -653,7 +702,9 @@ function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
653
702
  :Then(function()
654
703
  -- Give enough time for Roblox to replicate changes
655
704
  -- We probably could bump back to the loop but this has slightly better error messages
656
- return maid:GivePromise(PromiseUtils.delayed(5))
705
+ return maid:GivePromise(
706
+ PromiseUtils.delayed(self._sessionMessagingCloseDelaySeconds)
707
+ )
657
708
  end)
658
709
  :Then(function()
659
710
  return maid:GivePromise(promiseLoadUnlockedProfile(canStealLock, false))
@@ -682,7 +733,13 @@ function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
682
733
 
683
734
  return lockResult.lockedProfile, userIdList, metadata
684
735
  end)
685
- )
736
+ ):Catch(function(opError)
737
+ -- The datastore operation itself failed (e.g. 509), which is NOT lock contention and
738
+ -- will not resolve by retrying. Fail the load fast, preserving the original error.
739
+ if loadPromise:IsPending() then
740
+ loadPromise:Reject(DataStoreNonRetryableLoadError.new(opError))
741
+ end
742
+ end)
686
743
  end)
687
744
 
688
745
  loadPromise:Finally(function()
@@ -692,18 +749,27 @@ function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
692
749
  return loadPromise
693
750
  end
694
751
 
752
+ local retryOptions = {
753
+ exponential = self._loadRetryOptions.exponential,
754
+ initialWaitTime = self._loadRetryOptions.initialWaitTime,
755
+ maxAttempts = self._loadRetryOptions.maxAttempts,
756
+ printWarning = self._loadRetryOptions.printWarning,
757
+ -- Retry lock contention (the holder may release); fail fast on a fatal op failure.
758
+ shouldRetry = function(err)
759
+ return not DataStoreNonRetryableLoadError.isNonRetryableLoadError(err)
760
+ end,
761
+ }
762
+
695
763
  promise = self._maid
696
764
  :Add(PromiseRetryUtils.retry(function()
697
765
  return promiseLoadUnlockedProfile(false, true)
698
- end, {
699
- -- https://exponentialbackoffcalculator.com/
700
- -- 49.242 seconds
701
- exponential = 1.25,
702
- initialWaitTime = 6,
703
- maxAttempts = 6,
704
- printWarning = true,
705
- }))
766
+ end, retryOptions))
706
767
  :Catch(function(err)
768
+ -- A fatal op failure is not made loadable by stealing the lock; propagate it.
769
+ if DataStoreNonRetryableLoadError.isNonRetryableLoadError(err) then
770
+ return Promise.rejected(err)
771
+ end
772
+
707
773
  warn(
708
774
  string.format(
709
775
  "DataStorePromises.updateAsync(%q) (will steal session) -> warning - %s",
@@ -724,7 +790,7 @@ function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
724
790
  )
725
791
 
726
792
  self._promiseSessionLockingFailed:Resolve()
727
- return Promise.rejected(err)
793
+ return Promise.rejected(DataStoreNonRetryableLoadError.unwrapLoadError(err))
728
794
  end)
729
795
  else
730
796
  promise = self._maid
@@ -758,6 +824,11 @@ function DataStore._promiseGetAsyncNoCache(self: DataStore): Promise.Promise<()>
758
824
  )
759
825
  -- print(string.format("DataStorePromises.getAsync(%q) -> Got ", self._key), data)
760
826
  end
827
+ end, function(err)
828
+ -- Propagate the load failure. The explicit onRejected also marks the upstream promise as
829
+ -- consumed, so a cancellation rejection (e.g. the store destroyed mid-load) is not surfaced
830
+ -- as an uncaught exception on this branch.
831
+ return Promise.rejected(err)
761
832
  end)
762
833
  end
763
834
 
@@ -0,0 +1,237 @@
1
+ --!nonstrict
2
+ --[[
3
+ Integration coverage for DataStore against a mocked Roblox datastore: the load/save round-trip
4
+ and how it behaves when datastore operations fail (e.g. the 509 Personal-RCC block).
5
+
6
+ @class DataStore.spec.lua
7
+ ]]
8
+ local require = require(script.Parent.loader).load(script)
9
+
10
+ local DataStoreMock = require("DataStoreMock")
11
+ local DataStoreTestUtils = require("DataStoreTestUtils")
12
+ local Jest = require("Jest")
13
+ local PromiseTestUtils = require("PromiseTestUtils")
14
+
15
+ local describe = Jest.Globals.describe
16
+ local expect = Jest.Globals.expect
17
+ local it = Jest.Globals.it
18
+
19
+ -- Asserts the promise settled within the timeout, so a hung promise fails the test (here) instead of
20
+ -- freezing the runner on the following :Yield().
21
+ local function expectSettled(promise, timeout: number?)
22
+ expect(PromiseTestUtils.awaitSettled(promise, timeout)).toEqual(true)
23
+ end
24
+
25
+ describe("DataStore without session locking", function()
26
+ it("should load the default value when the key is empty", function()
27
+ local controller = DataStoreTestUtils.setup()
28
+ local dataStore = controller.newDataStore()
29
+
30
+ local promise = dataStore:Load("coins", 99)
31
+ expectSettled(promise)
32
+
33
+ local ok, value = promise:Yield()
34
+ expect(ok).toEqual(true)
35
+ expect(value).toEqual(99)
36
+
37
+ controller:destroy()
38
+ end)
39
+
40
+ it("should round-trip a stored value through the datastore", function()
41
+ local controller = DataStoreTestUtils.setup()
42
+
43
+ local writer = controller.newDataStore()
44
+ writer:Store("coins", 5)
45
+
46
+ local savePromise = writer:Save()
47
+ expectSettled(savePromise)
48
+ expect((savePromise:Yield())).toEqual(true)
49
+
50
+ local reader = controller.newDataStore()
51
+ local loadPromise = reader:Load("coins")
52
+ expectSettled(loadPromise)
53
+
54
+ local ok, value = loadPromise:Yield()
55
+ expect(ok).toEqual(true)
56
+ expect(value).toEqual(5)
57
+
58
+ controller:destroy()
59
+ end)
60
+
61
+ it("should round-trip multiple keys and load defaults for missing ones", function()
62
+ local controller = DataStoreTestUtils.setup()
63
+
64
+ local writer = controller.newDataStore()
65
+ writer:Store("coins", 5)
66
+ writer:Store("gems", 10)
67
+ expectSettled(writer:Save())
68
+
69
+ local reader = controller.newDataStore()
70
+ local promise = reader:LoadAll()
71
+ expectSettled(promise)
72
+
73
+ local ok, all = promise:Yield()
74
+ expect(ok).toEqual(true)
75
+ expect(all.coins).toEqual(5)
76
+ expect(all.gems).toEqual(10)
77
+
78
+ local missingPromise = reader:Load("missing", "default")
79
+ expectSettled(missingPromise)
80
+ expect((missingPromise:Wait())).toEqual("default")
81
+
82
+ controller:destroy()
83
+ end)
84
+
85
+ it("should round-trip substore values", function()
86
+ local controller = DataStoreTestUtils.setup()
87
+
88
+ local writer = controller.newDataStore()
89
+ writer:GetSubStore("inventory"):Store("sword", true)
90
+ expectSettled(writer:Save())
91
+
92
+ local reader = controller.newDataStore()
93
+ local promise = reader:GetSubStore("inventory"):Load("sword")
94
+ expectSettled(promise)
95
+
96
+ local ok, value = promise:Yield()
97
+ expect(ok).toEqual(true)
98
+ expect(value).toEqual(true)
99
+
100
+ controller:destroy()
101
+ end)
102
+
103
+ it("should delete a key so it no longer loads", function()
104
+ local controller = DataStoreTestUtils.setup()
105
+
106
+ local writer = controller.newDataStore()
107
+ writer:Store("a", 1)
108
+ writer:Store("b", 2)
109
+ expectSettled(writer:Save())
110
+
111
+ writer:Delete("a")
112
+ expectSettled(writer:Save())
113
+
114
+ local reader = controller.newDataStore()
115
+ local promise = reader:LoadAll()
116
+ expectSettled(promise)
117
+
118
+ local ok, all = promise:Yield()
119
+ expect(ok).toEqual(true)
120
+ expect(all.a).toEqual(nil)
121
+ expect(all.b).toEqual(2)
122
+
123
+ controller:destroy()
124
+ end)
125
+
126
+ it("should resolve a save when nothing is staged", function()
127
+ local controller = DataStoreTestUtils.setup()
128
+ local dataStore = controller.newDataStore()
129
+
130
+ expectSettled(dataStore:Load("x"))
131
+
132
+ local savePromise = dataStore:Save()
133
+ expectSettled(savePromise)
134
+ expect((savePromise:Yield())).toEqual(true)
135
+
136
+ controller:destroy()
137
+ end)
138
+
139
+ it("should report load failure (not hang) when the datastore is unavailable", function()
140
+ local controller = DataStoreTestUtils.setup()
141
+ controller.mock:FailAllRequests()
142
+ local dataStore = controller.newDataStore()
143
+
144
+ local promise = dataStore:PromiseLoadSuccessful()
145
+ expectSettled(promise, 5)
146
+
147
+ local ok, loadedOk = promise:Yield()
148
+ expect(ok).toEqual(true)
149
+ expect(loadedOk).toEqual(false)
150
+
151
+ controller:destroy()
152
+ end)
153
+
154
+ it("should reject a save when the datastore goes down after loading", function()
155
+ local controller = DataStoreTestUtils.setup()
156
+ local dataStore = controller.newDataStore()
157
+
158
+ expectSettled(dataStore:Load("x"))
159
+
160
+ controller.mock:FailAllRequests()
161
+ dataStore:Store("x", 1)
162
+
163
+ local savePromise = dataStore:Save()
164
+ expectSettled(savePromise, 5)
165
+ expect((savePromise:Yield())).toEqual(false)
166
+
167
+ controller:destroy()
168
+ end)
169
+
170
+ it("should mark DidLoadFail after a failed load", function()
171
+ local controller = DataStoreTestUtils.setup()
172
+ controller.mock:FailAllRequests()
173
+ local dataStore = controller.newDataStore()
174
+
175
+ local promise = dataStore:PromiseLoadSuccessful()
176
+ expectSettled(promise, 5)
177
+
178
+ expect(dataStore:DidLoadFail()).toEqual(true)
179
+
180
+ controller:destroy()
181
+ end)
182
+ end)
183
+
184
+ describe("DataStore with session locking", function()
185
+ it("should load successfully against a healthy datastore", function()
186
+ local controller = DataStoreTestUtils.setup()
187
+
188
+ local dataStore = controller.newDataStore()
189
+ dataStore:SetSessionLockingEnabled(true)
190
+ dataStore:SetUserIdList({ 1 })
191
+
192
+ local promise = dataStore:PromiseLoadSuccessful()
193
+ expectSettled(promise, 10)
194
+
195
+ local ok, loadedOk = promise:Yield()
196
+ expect(ok).toEqual(true)
197
+ expect(loadedOk).toEqual(true)
198
+
199
+ controller:destroy()
200
+ end)
201
+
202
+ it("surfaces a persistent datastore failure fast instead of hanging", function()
203
+ local controller = DataStoreTestUtils.setup()
204
+ controller.mock:FailAllRequests()
205
+
206
+ local dataStore = controller.newDataStore()
207
+ dataStore:SetSessionLockingEnabled(true)
208
+ dataStore:SetUserIdList({ 1 })
209
+
210
+ local promise = dataStore:PromiseLoadSuccessful()
211
+ expectSettled(promise, 5)
212
+
213
+ local ok, loadedOk = promise:Yield()
214
+ expect(ok).toEqual(true)
215
+ expect(loadedOk).toEqual(false)
216
+
217
+ controller:destroy()
218
+ end)
219
+
220
+ it("rejects a failed session-locked load with the preserved datastore error", function()
221
+ local controller = DataStoreTestUtils.setup()
222
+ controller.mock:FailAllRequests(DataStoreMock.OPERATION_NOT_ALLOWED_509)
223
+
224
+ local dataStore = controller.newDataStore()
225
+ dataStore:SetSessionLockingEnabled(true)
226
+ dataStore:SetUserIdList({ 1 })
227
+
228
+ -- PromiseLoadSuccessful maps the error to a boolean, so read the rejection from
229
+ -- PromiseViewUpToDate to assert the underlying datastore error propagates.
230
+ local outcome, err = PromiseTestUtils.awaitOutcome(dataStore:PromiseViewUpToDate())
231
+
232
+ expect(outcome).toEqual("rejected")
233
+ expect(string.find(tostring(err), "509", 1, true) ~= nil).toEqual(true)
234
+
235
+ controller:destroy()
236
+ end)
237
+ end)