@quenty/datastore 13.39.1 → 13.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/package.json +9 -9
- package/src/Server/DataStore.HookErrors.spec.lua +92 -0
- package/src/Server/DataStore.LoadErrors.spec.lua +133 -0
- package/src/Server/DataStore.Reentrance.spec.lua +119 -0
- package/src/Server/DataStore.SessionLock.spec.lua +478 -0
- package/src/Server/DataStore.SessionMessaging.spec.lua +71 -0
- package/src/Server/DataStore.TwoServerLock.spec.lua +190 -0
- package/src/Server/DataStore.lua +89 -18
- package/src/Server/DataStore.spec.lua +237 -0
- package/src/Server/DataStoreLockHelper.spec.lua +239 -0
- package/src/Server/DataStoreMessageHelper.spec.lua +134 -0
- package/src/Server/DataStoreNonRetryableLoadError.lua +61 -0
- package/src/Server/DataStoreTestUtils.lua +206 -0
- package/src/Server/GameDataStoreService.lua +26 -0
- package/src/Server/GameDataStoreService.spec.lua +215 -0
- package/src/Server/Mocks/DataStoreMock.lua +405 -0
- package/src/Server/Mocks/DataStoreMock.spec.lua +384 -0
- package/src/Server/Modules/DataStoreSnapshotUtils.spec.lua +91 -0
- package/src/Server/Modules/DataStoreStage.lua +8 -2
- package/src/Server/Modules/DataStoreStage.spec.lua +539 -0
- package/src/Server/Modules/DataStoreWriter.spec.lua +399 -0
- package/src/Server/PlayerDataStoreManager.RemovalCallbacks.spec.lua +99 -0
- package/src/Server/PlayerDataStoreManager.lua +36 -4
- package/src/Server/PlayerDataStoreManager.spec.lua +202 -0
- package/src/Server/PlayerDataStoreService.lua +18 -0
- package/src/Server/PlayerDataStoreService.spec.lua +218 -0
- package/src/Server/PrivateServerDataStoreService.lua +23 -1
- package/src/Server/PrivateServerDataStoreService.spec.lua +242 -0
- package/src/Server/Utility/DataStorePromises.lua +18 -5
- package/src/Server/Utility/DataStorePromises.spec.lua +363 -0
- package/src/jest.config.lua +3 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
--!nonstrict
|
|
2
|
+
--[[
|
|
3
|
+
Characterization coverage for DataStore session locking, exercised through a real DataStore
|
|
4
|
+
against a mocked Roblox datastore. It pins the observable behaviour of the lock lifecycle: a
|
|
5
|
+
healthy acquire wraps the stored profile in a lock envelope, SaveAndCloseSession releases it,
|
|
6
|
+
user data survives a lock/unlock round-trip, and a stale lock left by a dead session is stolen.
|
|
7
|
+
|
|
8
|
+
@class DataStoreLockHelper.spec.lua
|
|
9
|
+
]]
|
|
10
|
+
local require = require(script.Parent.loader).load(script)
|
|
11
|
+
|
|
12
|
+
local DataStoreTestUtils = require("DataStoreTestUtils")
|
|
13
|
+
local Jest = require("Jest")
|
|
14
|
+
local PromiseTestUtils = require("PromiseTestUtils")
|
|
15
|
+
|
|
16
|
+
local describe = Jest.Globals.describe
|
|
17
|
+
local expect = Jest.Globals.expect
|
|
18
|
+
local it = Jest.Globals.it
|
|
19
|
+
|
|
20
|
+
describe("DataStore session locking", function()
|
|
21
|
+
it("wraps the stored profile in a lock envelope on a healthy acquire", function()
|
|
22
|
+
local controller = DataStoreTestUtils.setup()
|
|
23
|
+
|
|
24
|
+
local dataStore = controller.newDataStore()
|
|
25
|
+
dataStore:SetSessionLockingEnabled(true)
|
|
26
|
+
dataStore:SetUserIdList({ 1 })
|
|
27
|
+
|
|
28
|
+
local promise = dataStore:PromiseLoadSuccessful()
|
|
29
|
+
if not PromiseTestUtils.awaitSettled(promise, 10) then
|
|
30
|
+
expect("hung").toEqual("settled")
|
|
31
|
+
controller:destroy()
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
local ok, loadedOk = promise:Yield()
|
|
36
|
+
expect(ok).toEqual(true)
|
|
37
|
+
expect(loadedOk).toEqual(true)
|
|
38
|
+
|
|
39
|
+
local raw = controller.mock:GetRaw("player_1")
|
|
40
|
+
expect(type(raw)).toEqual("table")
|
|
41
|
+
expect(type(raw.lock)).toEqual("table")
|
|
42
|
+
expect(raw.lock.ActiveSession).never.toBeNil()
|
|
43
|
+
expect(raw.lock.ActiveSession.SessionId).toEqual(dataStore:GetSessionId())
|
|
44
|
+
|
|
45
|
+
controller:destroy()
|
|
46
|
+
end)
|
|
47
|
+
|
|
48
|
+
it("releases the lock on SaveAndCloseSession so a new session can load", function()
|
|
49
|
+
local controller = DataStoreTestUtils.setup()
|
|
50
|
+
|
|
51
|
+
local sessionA = controller.newDataStore()
|
|
52
|
+
sessionA:SetSessionLockingEnabled(true)
|
|
53
|
+
sessionA:SetUserIdList({ 1 })
|
|
54
|
+
|
|
55
|
+
local loadA = sessionA:PromiseLoadSuccessful()
|
|
56
|
+
if not PromiseTestUtils.awaitSettled(loadA, 10) then
|
|
57
|
+
expect("hung").toEqual("settled")
|
|
58
|
+
controller:destroy()
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
expect((loadA:Yield())).toEqual(true)
|
|
62
|
+
|
|
63
|
+
local closePromise = sessionA:SaveAndCloseSession()
|
|
64
|
+
if not PromiseTestUtils.awaitSettled(closePromise, 10) then
|
|
65
|
+
expect("hung").toEqual("settled")
|
|
66
|
+
controller:destroy()
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
expect((closePromise:Yield())).toEqual(true)
|
|
70
|
+
|
|
71
|
+
-- Closing strips the lock envelope back off the stored value.
|
|
72
|
+
local raw = controller.mock:GetRaw("player_1")
|
|
73
|
+
expect(type(raw)).toEqual("table")
|
|
74
|
+
expect(raw.lock).toEqual(nil)
|
|
75
|
+
|
|
76
|
+
local sessionB = controller.newDataStore()
|
|
77
|
+
sessionB:SetSessionLockingEnabled(true)
|
|
78
|
+
sessionB:SetUserIdList({ 1 })
|
|
79
|
+
|
|
80
|
+
local loadB = sessionB:PromiseLoadSuccessful()
|
|
81
|
+
if not PromiseTestUtils.awaitSettled(loadB, 10) then
|
|
82
|
+
expect("hung").toEqual("settled")
|
|
83
|
+
controller:destroy()
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
expect((loadB:Yield())).toEqual(true)
|
|
87
|
+
|
|
88
|
+
controller:destroy()
|
|
89
|
+
end)
|
|
90
|
+
|
|
91
|
+
it("preserves user data across a lock/unlock round-trip", function()
|
|
92
|
+
local controller = DataStoreTestUtils.setup()
|
|
93
|
+
|
|
94
|
+
local sessionA = controller.newDataStore()
|
|
95
|
+
sessionA:SetSessionLockingEnabled(true)
|
|
96
|
+
sessionA:SetUserIdList({ 1 })
|
|
97
|
+
sessionA:Store("coins", 5)
|
|
98
|
+
|
|
99
|
+
local closePromise = sessionA:SaveAndCloseSession()
|
|
100
|
+
if not PromiseTestUtils.awaitSettled(closePromise, 10) then
|
|
101
|
+
expect("hung").toEqual("settled")
|
|
102
|
+
controller:destroy()
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
expect((closePromise:Yield())).toEqual(true)
|
|
106
|
+
|
|
107
|
+
local sessionB = controller.newDataStore()
|
|
108
|
+
sessionB:SetSessionLockingEnabled(true)
|
|
109
|
+
sessionB:SetUserIdList({ 1 })
|
|
110
|
+
|
|
111
|
+
local loadPromise = sessionB:Load("coins")
|
|
112
|
+
if not PromiseTestUtils.awaitSettled(loadPromise, 10) then
|
|
113
|
+
expect("hung").toEqual("settled")
|
|
114
|
+
controller:destroy()
|
|
115
|
+
return
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
local ok, value = loadPromise:Yield()
|
|
119
|
+
expect(ok).toEqual(true)
|
|
120
|
+
expect(value).toEqual(5)
|
|
121
|
+
|
|
122
|
+
controller:destroy()
|
|
123
|
+
end)
|
|
124
|
+
|
|
125
|
+
it("steals a stale lock left by a dead session", function()
|
|
126
|
+
local controller = DataStoreTestUtils.setup()
|
|
127
|
+
|
|
128
|
+
-- Seed a lock owned by a long-dead session. LastUpdateTime is far enough in the past that
|
|
129
|
+
-- os.time() - LastUpdateTime exceeds GetAutoSaveTimeSeconds() * 2.1 (default 300 * 2.1 = 630s),
|
|
130
|
+
-- so the lock is stolen on the first acquire attempt (no retry backoff). Seed user data too,
|
|
131
|
+
-- to prove it survives the steal.
|
|
132
|
+
controller.mock:SetRaw("player_1", {
|
|
133
|
+
coins = 7,
|
|
134
|
+
lock = {
|
|
135
|
+
LastUpdateTime = os.time() - 1000000,
|
|
136
|
+
ActiveSession = {
|
|
137
|
+
SessionId = "stale-session-id",
|
|
138
|
+
PlaceId = 987654321,
|
|
139
|
+
JobId = "stale-job-id",
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
local dataStore = controller.newDataStore()
|
|
145
|
+
dataStore:SetSessionLockingEnabled(true)
|
|
146
|
+
dataStore:SetUserIdList({ 1 })
|
|
147
|
+
|
|
148
|
+
local promise = dataStore:PromiseLoadSuccessful()
|
|
149
|
+
if not PromiseTestUtils.awaitSettled(promise, 10) then
|
|
150
|
+
expect("hung").toEqual("settled")
|
|
151
|
+
controller:destroy()
|
|
152
|
+
return
|
|
153
|
+
end
|
|
154
|
+
expect((promise:Yield())).toEqual(true)
|
|
155
|
+
|
|
156
|
+
local raw = controller.mock:GetRaw("player_1")
|
|
157
|
+
expect(raw.lock.ActiveSession.SessionId).toEqual(dataStore:GetSessionId())
|
|
158
|
+
|
|
159
|
+
-- The dead session's user data survived the steal.
|
|
160
|
+
local loadPromise = dataStore:Load("coins")
|
|
161
|
+
if not PromiseTestUtils.awaitSettled(loadPromise, 10) then
|
|
162
|
+
expect("hung").toEqual("settled")
|
|
163
|
+
controller:destroy()
|
|
164
|
+
return
|
|
165
|
+
end
|
|
166
|
+
expect((loadPromise:Wait())).toEqual(7)
|
|
167
|
+
|
|
168
|
+
controller:destroy()
|
|
169
|
+
end)
|
|
170
|
+
|
|
171
|
+
it("fires SessionStolen when saving over a lock held by another session", function()
|
|
172
|
+
local controller = DataStoreTestUtils.setup()
|
|
173
|
+
|
|
174
|
+
local dataStore = controller.newDataStore()
|
|
175
|
+
dataStore:SetSessionLockingEnabled(true)
|
|
176
|
+
dataStore:SetUserIdList({ 1 })
|
|
177
|
+
|
|
178
|
+
local loadPromise = dataStore:PromiseLoadSuccessful()
|
|
179
|
+
if not PromiseTestUtils.awaitSettled(loadPromise, 10) then
|
|
180
|
+
expect("hung").toEqual("settled")
|
|
181
|
+
controller:destroy()
|
|
182
|
+
return
|
|
183
|
+
end
|
|
184
|
+
expect((loadPromise:Yield())).toEqual(true)
|
|
185
|
+
|
|
186
|
+
-- Another session steals the lock out from under us directly in the datastore.
|
|
187
|
+
controller.mock:SetRaw("player_1", {
|
|
188
|
+
coins = 1,
|
|
189
|
+
lock = {
|
|
190
|
+
LastUpdateTime = os.time(),
|
|
191
|
+
ActiveSession = {
|
|
192
|
+
SessionId = "thief-session-id",
|
|
193
|
+
PlaceId = 111222333,
|
|
194
|
+
JobId = "thief-job-id",
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
local stolenBy = nil
|
|
200
|
+
dataStore.SessionStolen:Connect(function(session)
|
|
201
|
+
stolenBy = session
|
|
202
|
+
end)
|
|
203
|
+
|
|
204
|
+
dataStore:Store("coins", 2)
|
|
205
|
+
local savePromise = dataStore:Save()
|
|
206
|
+
if not PromiseTestUtils.awaitSettled(savePromise, 10) then
|
|
207
|
+
expect("hung").toEqual("settled")
|
|
208
|
+
controller:destroy()
|
|
209
|
+
return
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
expect(stolenBy).never.toBeNil()
|
|
213
|
+
expect(stolenBy.SessionId).toEqual("thief-session-id")
|
|
214
|
+
|
|
215
|
+
controller:destroy()
|
|
216
|
+
end)
|
|
217
|
+
|
|
218
|
+
it("surfaces a locked-load datastore failure fast instead of hanging", function()
|
|
219
|
+
local controller = DataStoreTestUtils.setup()
|
|
220
|
+
controller.mock:FailAllRequests()
|
|
221
|
+
|
|
222
|
+
local dataStore = controller.newDataStore()
|
|
223
|
+
dataStore:SetSessionLockingEnabled(true)
|
|
224
|
+
dataStore:SetUserIdList({ 1 })
|
|
225
|
+
|
|
226
|
+
local promise = dataStore:PromiseLoadSuccessful()
|
|
227
|
+
if not PromiseTestUtils.awaitSettled(promise, 5) then
|
|
228
|
+
expect("hung").toEqual("settled")
|
|
229
|
+
controller:destroy()
|
|
230
|
+
return
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
local ok, loadedOk = promise:Yield()
|
|
234
|
+
expect(ok).toEqual(true)
|
|
235
|
+
expect(loadedOk).toEqual(false)
|
|
236
|
+
|
|
237
|
+
controller:destroy()
|
|
238
|
+
end)
|
|
239
|
+
end)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
--!nonstrict
|
|
2
|
+
--[[
|
|
3
|
+
Coverage for DataStoreMessageHelper, the coordinator that drives graceful cross-server
|
|
4
|
+
session-close over MessagingService. The full request/complete handshake needs a second server
|
|
5
|
+
to answer, so cross-server behaviour is characterized rather than asserted; what a single server
|
|
6
|
+
can verify (construction, the "cannot message self" guard, messaging wiring, and the
|
|
7
|
+
MessagingServiceUtils.toHumanReadable formatter) is exercised here.
|
|
8
|
+
|
|
9
|
+
@class DataStoreMessageHelper.spec.lua
|
|
10
|
+
]]
|
|
11
|
+
local require = require(script.Parent.loader).load(script)
|
|
12
|
+
|
|
13
|
+
local DataStoreMessageHelper = require("DataStoreMessageHelper")
|
|
14
|
+
local DataStoreTestUtils = require("DataStoreTestUtils")
|
|
15
|
+
local Jest = require("Jest")
|
|
16
|
+
local MessagingServiceUtils = require("MessagingServiceUtils")
|
|
17
|
+
|
|
18
|
+
local describe = Jest.Globals.describe
|
|
19
|
+
local expect = Jest.Globals.expect
|
|
20
|
+
local it = Jest.Globals.it
|
|
21
|
+
|
|
22
|
+
describe("DataStoreMessageHelper.new", function()
|
|
23
|
+
it("should construct against a real ServiceBag and DataStore", function()
|
|
24
|
+
local controller = DataStoreTestUtils.setup()
|
|
25
|
+
local helper = controller.newMessageHelper()
|
|
26
|
+
|
|
27
|
+
expect(helper).never.toBeNil()
|
|
28
|
+
|
|
29
|
+
controller:destroy()
|
|
30
|
+
end)
|
|
31
|
+
|
|
32
|
+
it("should expose the ServiceBag it was built with", function()
|
|
33
|
+
local controller = DataStoreTestUtils.setup()
|
|
34
|
+
local helper = controller.newMessageHelper()
|
|
35
|
+
|
|
36
|
+
expect((helper:GetServiceBag() == controller.serviceBag)).toEqual(true)
|
|
37
|
+
|
|
38
|
+
controller:destroy()
|
|
39
|
+
end)
|
|
40
|
+
|
|
41
|
+
it("should reject a nil serviceBag", function()
|
|
42
|
+
local controller = DataStoreTestUtils.setup()
|
|
43
|
+
local dataStore = controller.newDataStore()
|
|
44
|
+
|
|
45
|
+
expect(function()
|
|
46
|
+
DataStoreMessageHelper.new(nil, dataStore)
|
|
47
|
+
end).toThrow("No serviceBag")
|
|
48
|
+
|
|
49
|
+
controller:destroy()
|
|
50
|
+
end)
|
|
51
|
+
|
|
52
|
+
it("should not error on construct then Destroy", function()
|
|
53
|
+
local controller = DataStoreTestUtils.setup()
|
|
54
|
+
local helper = controller.newMessageHelper()
|
|
55
|
+
|
|
56
|
+
-- The test destroys the helper itself; the maid then skips it, so there is no double-Destroy.
|
|
57
|
+
expect(function()
|
|
58
|
+
helper:Destroy()
|
|
59
|
+
end).never.toThrow()
|
|
60
|
+
|
|
61
|
+
controller:destroy()
|
|
62
|
+
end)
|
|
63
|
+
end)
|
|
64
|
+
|
|
65
|
+
describe("DataStoreMessageHelper.PromiseSendSessionMessage", function()
|
|
66
|
+
it("should refuse to message its own session synchronously", function()
|
|
67
|
+
local controller = DataStoreTestUtils.setup()
|
|
68
|
+
local helper, dataStore = controller.newMessageHelper()
|
|
69
|
+
|
|
70
|
+
local ownSessionId = dataStore:GetSessionId()
|
|
71
|
+
expect(function()
|
|
72
|
+
helper:PromiseSendSessionMessage(1, "job", ownSessionId, {
|
|
73
|
+
type = "close-session",
|
|
74
|
+
requesterSessionId = ownSessionId,
|
|
75
|
+
})
|
|
76
|
+
end).toThrow("Cannot message self")
|
|
77
|
+
|
|
78
|
+
controller:destroy()
|
|
79
|
+
end)
|
|
80
|
+
end)
|
|
81
|
+
|
|
82
|
+
describe("DataStoreMessageHelper.PromiseCloseSessionGraceful", function()
|
|
83
|
+
it("should return a promise (cross-server outcome is not unit-testable single-server)", function()
|
|
84
|
+
-- The graceful handshake needs a second server to answer, so we only assert it returns a
|
|
85
|
+
-- promise; its settlement is environment dependent and covered by real multi-server usage.
|
|
86
|
+
local controller = DataStoreTestUtils.setup()
|
|
87
|
+
local helper = controller.newMessageHelper()
|
|
88
|
+
|
|
89
|
+
local promise = helper:PromiseCloseSessionGraceful(1, "some-other-job", "some-other-session")
|
|
90
|
+
expect(promise).never.toBeNil()
|
|
91
|
+
|
|
92
|
+
controller:destroy()
|
|
93
|
+
end)
|
|
94
|
+
end)
|
|
95
|
+
|
|
96
|
+
describe("DataStore.SetSessionMessagingEnabled wiring", function()
|
|
97
|
+
it("should enable then disable session messaging without erroring", function()
|
|
98
|
+
local controller = DataStoreTestUtils.setup()
|
|
99
|
+
local dataStore = controller.newDataStore()
|
|
100
|
+
-- Messaging is documented to work alongside session locking; enable both like real usage.
|
|
101
|
+
dataStore:SetSessionLockingEnabled(true)
|
|
102
|
+
dataStore:SetUserIdList({ 1 })
|
|
103
|
+
|
|
104
|
+
expect(function()
|
|
105
|
+
dataStore:SetSessionMessagingEnabled(true, controller.serviceBag)
|
|
106
|
+
end).never.toThrow()
|
|
107
|
+
|
|
108
|
+
expect(function()
|
|
109
|
+
dataStore:SetSessionMessagingEnabled(false)
|
|
110
|
+
end).never.toThrow()
|
|
111
|
+
|
|
112
|
+
controller:destroy()
|
|
113
|
+
end)
|
|
114
|
+
end)
|
|
115
|
+
|
|
116
|
+
describe("MessagingServiceUtils.toHumanReadable", function()
|
|
117
|
+
it("should JSON-encode a table message", function()
|
|
118
|
+
local result = MessagingServiceUtils.toHumanReadable({
|
|
119
|
+
type = "close-session",
|
|
120
|
+
requesterSessionId = "abc",
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect((type(result))).toEqual("string")
|
|
124
|
+
-- JSON key order is not guaranteed, so pin the shape, not the exact string.
|
|
125
|
+
expect((string.sub(result, 1, 1))).toEqual("{")
|
|
126
|
+
end)
|
|
127
|
+
|
|
128
|
+
it("should stringify a non-table message", function()
|
|
129
|
+
expect((MessagingServiceUtils.toHumanReadable(5))).toEqual("5")
|
|
130
|
+
expect((MessagingServiceUtils.toHumanReadable("hi"))).toEqual("hi")
|
|
131
|
+
expect((MessagingServiceUtils.toHumanReadable(true))).toEqual("true")
|
|
132
|
+
expect((MessagingServiceUtils.toHumanReadable(nil))).toEqual("nil")
|
|
133
|
+
end)
|
|
134
|
+
end)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[=[
|
|
3
|
+
Wraps a datastore-operation failure during a session-locked load so it is distinguishable from
|
|
4
|
+
lock contention. Op failures (e.g. 509) will not resolve by retrying -- Roblox already retries
|
|
5
|
+
internally -- so we fail fast rather than grinding the acquire backoff, while lock contention (a
|
|
6
|
+
successful op that returns a locked profile) keeps retrying. The original error is preserved.
|
|
7
|
+
|
|
8
|
+
@server
|
|
9
|
+
@class DataStoreNonRetryableLoadError
|
|
10
|
+
]=]
|
|
11
|
+
|
|
12
|
+
local DataStoreNonRetryableLoadError = {}
|
|
13
|
+
DataStoreNonRetryableLoadError.ClassName = "DataStoreNonRetryableLoadError"
|
|
14
|
+
DataStoreNonRetryableLoadError.__index = DataStoreNonRetryableLoadError
|
|
15
|
+
|
|
16
|
+
export type DataStoreNonRetryableLoadError = typeof(setmetatable(
|
|
17
|
+
{} :: {
|
|
18
|
+
innerError: any,
|
|
19
|
+
},
|
|
20
|
+
DataStoreNonRetryableLoadError
|
|
21
|
+
))
|
|
22
|
+
|
|
23
|
+
function DataStoreNonRetryableLoadError.__tostring(self: DataStoreNonRetryableLoadError): string
|
|
24
|
+
return tostring(self.innerError)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
--[=[
|
|
28
|
+
Wraps an inner error so it is treated as non-retryable during a session-locked load.
|
|
29
|
+
|
|
30
|
+
@param innerError any
|
|
31
|
+
@return DataStoreNonRetryableLoadError
|
|
32
|
+
]=]
|
|
33
|
+
function DataStoreNonRetryableLoadError.new(innerError: any): DataStoreNonRetryableLoadError
|
|
34
|
+
return setmetatable({ innerError = innerError }, DataStoreNonRetryableLoadError)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
--[=[
|
|
38
|
+
Returns true if the given error is a [DataStoreNonRetryableLoadError].
|
|
39
|
+
|
|
40
|
+
@param err any
|
|
41
|
+
@return boolean
|
|
42
|
+
]=]
|
|
43
|
+
function DataStoreNonRetryableLoadError.isNonRetryableLoadError(err: any): boolean
|
|
44
|
+
return type(err) == "table" and getmetatable(err) == DataStoreNonRetryableLoadError
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
--[=[
|
|
48
|
+
Returns the original inner error if the given error is a [DataStoreNonRetryableLoadError],
|
|
49
|
+
otherwise returns the error unchanged.
|
|
50
|
+
|
|
51
|
+
@param err any
|
|
52
|
+
@return any
|
|
53
|
+
]=]
|
|
54
|
+
function DataStoreNonRetryableLoadError.unwrapLoadError(err: any): any
|
|
55
|
+
if DataStoreNonRetryableLoadError.isNonRetryableLoadError(err) then
|
|
56
|
+
return err.innerError
|
|
57
|
+
end
|
|
58
|
+
return err
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
return DataStoreNonRetryableLoadError
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
--!nonstrict
|
|
2
|
+
--[=[
|
|
3
|
+
Shared setup helpers for the DataStore server specs. The two controller builders --
|
|
4
|
+
[DataStoreTestUtils.setup] (raw DataStores) and [DataStoreTestUtils.setupDataStoreManager]
|
|
5
|
+
(a [PlayerDataStoreManager]) -- each own a [Maid] and register everything they create on it, so a
|
|
6
|
+
single `controller:destroy()` tears it all down: the stores, the auto-save loop each starts once
|
|
7
|
+
loaded, the helpers, the manager, and the service bag.
|
|
8
|
+
|
|
9
|
+
@class DataStoreTestUtils
|
|
10
|
+
]=]
|
|
11
|
+
|
|
12
|
+
local require = require(script.Parent.loader).load(script)
|
|
13
|
+
|
|
14
|
+
local DataStore = require("DataStore")
|
|
15
|
+
local DataStoreLockHelper = require("DataStoreLockHelper")
|
|
16
|
+
local DataStoreMessageHelper = require("DataStoreMessageHelper")
|
|
17
|
+
local DataStoreMock = require("DataStoreMock")
|
|
18
|
+
local Maid = require("Maid")
|
|
19
|
+
local MessagingServiceMock = require("MessagingServiceMock")
|
|
20
|
+
local PlayerDataStoreManager = require("PlayerDataStoreManager")
|
|
21
|
+
local PromiseTestUtils = require("PromiseTestUtils")
|
|
22
|
+
local ServiceBag = require("ServiceBag")
|
|
23
|
+
|
|
24
|
+
local DataStoreTestUtils = {}
|
|
25
|
+
|
|
26
|
+
--[=[
|
|
27
|
+
Builds the controller the DataStore specs share: a fresh [DataStoreMock], a [ServiceBag] with an
|
|
28
|
+
in-process [MessagingServiceMock] injected (so messaging-enabled stores never touch the real
|
|
29
|
+
MessagingService), and builder methods that own everything they create on one Maid. `destroy()`
|
|
30
|
+
tears it all down. Every builder defaults its key to `"player_1"`; pass a key to override.
|
|
31
|
+
|
|
32
|
+
Fields: `mock`, `serviceBag`.
|
|
33
|
+
Builders: `newDataStore(key?)`, `newSessionLockedStore(key?, userIdList?)`, `newLockHelper(key?)`
|
|
34
|
+
(returns `helper, dataStore`), `newMessageHelper(dataStore?)` (returns `helper, dataStore`),
|
|
35
|
+
`newServer(opts?)` (returns `dataStore, helper?`; `opts` = `{ key?, messaging?, autoCloseOnRequest? }`).
|
|
36
|
+
Helpers: `awaitOwn(dataStore)` -> boolean (loads and reports whether we own the session).
|
|
37
|
+
|
|
38
|
+
@return { ... }
|
|
39
|
+
]=]
|
|
40
|
+
function DataStoreTestUtils.setup()
|
|
41
|
+
local maid = Maid.new()
|
|
42
|
+
|
|
43
|
+
local mock = DataStoreMock.new()
|
|
44
|
+
local serviceBag = DataStoreTestUtils.newServiceBag(maid, MessagingServiceMock.new())
|
|
45
|
+
|
|
46
|
+
local function newDataStore(key)
|
|
47
|
+
return DataStoreTestUtils.newDataStore(maid, mock, key or "player_1")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
local function newSessionLockedStore(key, userIdList)
|
|
51
|
+
return DataStoreTestUtils.newSessionLockedStore(maid, mock, key or "player_1", userIdList)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
local function newLockHelper(key)
|
|
55
|
+
local dataStore = newDataStore(key)
|
|
56
|
+
local helper = maid:Add(DataStoreLockHelper.new(dataStore))
|
|
57
|
+
return helper, dataStore
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
local function newMessageHelper(dataStore)
|
|
61
|
+
dataStore = dataStore or newDataStore()
|
|
62
|
+
return DataStoreTestUtils.newMessageHelper(maid, serviceBag, dataStore), dataStore
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
local function newServer(opts)
|
|
66
|
+
opts = opts or {}
|
|
67
|
+
local dataStore = newSessionLockedStore(opts.key)
|
|
68
|
+
local helper
|
|
69
|
+
if opts.messaging then
|
|
70
|
+
dataStore:SetSessionMessagingEnabled(true, serviceBag)
|
|
71
|
+
helper = newMessageHelper(dataStore)
|
|
72
|
+
end
|
|
73
|
+
if opts.autoCloseOnRequest then
|
|
74
|
+
dataStore.SessionCloseRequested:Connect(function()
|
|
75
|
+
dataStore:SaveAndCloseSession()
|
|
76
|
+
end)
|
|
77
|
+
end
|
|
78
|
+
return dataStore, helper
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
local function awaitOwn(dataStore)
|
|
82
|
+
local promise = dataStore:PromiseLoadSuccessful()
|
|
83
|
+
if not PromiseTestUtils.awaitSettled(promise, 10) then
|
|
84
|
+
return false
|
|
85
|
+
end
|
|
86
|
+
local ok, loadedOk = promise:Yield()
|
|
87
|
+
return ok and loadedOk
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
mock = mock,
|
|
92
|
+
serviceBag = serviceBag,
|
|
93
|
+
newDataStore = newDataStore,
|
|
94
|
+
newSessionLockedStore = newSessionLockedStore,
|
|
95
|
+
newLockHelper = newLockHelper,
|
|
96
|
+
newMessageHelper = newMessageHelper,
|
|
97
|
+
newServer = newServer,
|
|
98
|
+
awaitOwn = awaitOwn,
|
|
99
|
+
destroy = function()
|
|
100
|
+
maid:DoCleaning()
|
|
101
|
+
end,
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
--[=[
|
|
106
|
+
Builds the controller the [PlayerDataStoreManager] specs share: a session-locked manager wired to
|
|
107
|
+
a fresh [DataStoreMock] (keyed `user_<userId>`), all owned by a Maid. `destroy()` tears down the
|
|
108
|
+
manager (and the loaded stores whose auto-save loops it owns) and the service bag.
|
|
109
|
+
|
|
110
|
+
Fields: `manager`, `mock`, `serviceBag`.
|
|
111
|
+
Helpers: `storeAndAwaitLock()` -> boolean -- stores a value on user 1's store and waits for the
|
|
112
|
+
session-locked load to write the lock envelope.
|
|
113
|
+
|
|
114
|
+
@return { manager: PlayerDataStoreManager, mock: DataStoreMock, ... }
|
|
115
|
+
]=]
|
|
116
|
+
function DataStoreTestUtils.setupDataStoreManager()
|
|
117
|
+
local maid = Maid.new()
|
|
118
|
+
|
|
119
|
+
local mock = DataStoreMock.new()
|
|
120
|
+
local serviceBag = DataStoreTestUtils.newServiceBag(maid)
|
|
121
|
+
|
|
122
|
+
local manager = maid:Add(PlayerDataStoreManager.new(serviceBag, mock, function(userId)
|
|
123
|
+
return "user_" .. tostring(userId)
|
|
124
|
+
end, true))
|
|
125
|
+
|
|
126
|
+
local function storeAndAwaitLock()
|
|
127
|
+
local dataStore = manager:GetDataStore(1)
|
|
128
|
+
dataStore:Store("coins", 5)
|
|
129
|
+
-- The session-locked load acquires the lock (writes the envelope) before we remove.
|
|
130
|
+
return PromiseTestUtils.awaitValue(function()
|
|
131
|
+
local raw = mock:GetRaw("user_1")
|
|
132
|
+
return raw ~= nil and raw.lock ~= nil
|
|
133
|
+
end, 10)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
manager = manager,
|
|
138
|
+
mock = mock,
|
|
139
|
+
serviceBag = serviceBag,
|
|
140
|
+
storeAndAwaitLock = storeAndAwaitLock,
|
|
141
|
+
destroy = function()
|
|
142
|
+
maid:DoCleaning()
|
|
143
|
+
end,
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
--[=[
|
|
148
|
+
Builds a [ServiceBag] with PlaceMessagingService registered and Init/Start'd, owned by the maid.
|
|
149
|
+
Pass a Roblox MessagingService mock to inject it into PlaceMessagingService between Init and Start.
|
|
150
|
+
|
|
151
|
+
@param maid Maid
|
|
152
|
+
@param robloxMessagingService MessagingServiceMock? -- injected when provided
|
|
153
|
+
@return ServiceBag
|
|
154
|
+
]=]
|
|
155
|
+
function DataStoreTestUtils.newServiceBag(maid, robloxMessagingService)
|
|
156
|
+
local serviceBag = maid:Add(ServiceBag.new())
|
|
157
|
+
local placeMessagingService = serviceBag:GetService(require("PlaceMessagingService"))
|
|
158
|
+
serviceBag:Init()
|
|
159
|
+
if robloxMessagingService then
|
|
160
|
+
placeMessagingService:SetRobloxMessagingService(robloxMessagingService)
|
|
161
|
+
end
|
|
162
|
+
serviceBag:Start()
|
|
163
|
+
return serviceBag
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
--[=[
|
|
167
|
+
Builds a [DataStore] over `mock` and owns it with the maid.
|
|
168
|
+
|
|
169
|
+
@param maid Maid
|
|
170
|
+
@param mock DataStoreMock
|
|
171
|
+
@param key string
|
|
172
|
+
@return DataStore
|
|
173
|
+
]=]
|
|
174
|
+
function DataStoreTestUtils.newDataStore(maid, mock, key)
|
|
175
|
+
return maid:Add(DataStore.new(mock, key))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
--[=[
|
|
179
|
+
Builds a session-locked [DataStore] over `mock` and owns it with the maid.
|
|
180
|
+
|
|
181
|
+
@param maid Maid
|
|
182
|
+
@param mock DataStoreMock
|
|
183
|
+
@param key string
|
|
184
|
+
@param userIdList { number }? -- defaults to { 1 }
|
|
185
|
+
@return DataStore
|
|
186
|
+
]=]
|
|
187
|
+
function DataStoreTestUtils.newSessionLockedStore(maid, mock, key, userIdList)
|
|
188
|
+
local dataStore = maid:Add(DataStore.new(mock, key))
|
|
189
|
+
dataStore:SetSessionLockingEnabled(true)
|
|
190
|
+
dataStore:SetUserIdList(userIdList or { 1 })
|
|
191
|
+
return dataStore
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
--[=[
|
|
195
|
+
Builds a [DataStoreMessageHelper] over `dataStore` and owns it with the maid.
|
|
196
|
+
|
|
197
|
+
@param maid Maid
|
|
198
|
+
@param serviceBag ServiceBag
|
|
199
|
+
@param dataStore DataStore
|
|
200
|
+
@return DataStoreMessageHelper
|
|
201
|
+
]=]
|
|
202
|
+
function DataStoreTestUtils.newMessageHelper(maid, serviceBag, dataStore)
|
|
203
|
+
return maid:Add(DataStoreMessageHelper.new(serviceBag, dataStore))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
return DataStoreTestUtils
|
|
@@ -37,6 +37,20 @@ function GameDataStoreService.Init(self: GameDataStoreService, serviceBag: Servi
|
|
|
37
37
|
self._bindToCloseService = self._serviceBag:GetService(require("BindToCloseService"))
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
--[=[
|
|
41
|
+
Injects the underlying datastore to use instead of resolving a real one. Accepts a real
|
|
42
|
+
datastore or a [DataStoreMock]. Intended for testing; must be called before the datastore
|
|
43
|
+
is first resolved.
|
|
44
|
+
|
|
45
|
+
@param robloxDataStore DataStore | DataStoreMock
|
|
46
|
+
]=]
|
|
47
|
+
function GameDataStoreService.SetRobloxDataStore(self: GameDataStoreService, robloxDataStore: any): ()
|
|
48
|
+
assert(DataStorePromises.isDataStore(robloxDataStore), "Bad robloxDataStore")
|
|
49
|
+
assert(not self._robloxDataStorePromise, "Already resolved robloxDataStore, cannot override")
|
|
50
|
+
|
|
51
|
+
self._robloxDataStorePromise = Promise.resolved(robloxDataStore)
|
|
52
|
+
end
|
|
53
|
+
|
|
40
54
|
--[=[
|
|
41
55
|
Promises a DataStore for the current game that is synchronized every 5 seconds.
|
|
42
56
|
|
|
@@ -61,6 +75,18 @@ function GameDataStoreService.PromiseDataStore(self: GameDataStoreService): Prom
|
|
|
61
75
|
end)
|
|
62
76
|
end))
|
|
63
77
|
|
|
78
|
+
-- On service teardown (hot reload / tests) flush and destroy the store. Save() is a best-effort
|
|
79
|
+
-- synchronous write before Destroy() cancels it. The guard skips it if the game-close callback
|
|
80
|
+
-- above already tore the store down.
|
|
81
|
+
self._maid:GiveTask(function()
|
|
82
|
+
if not dataStore.Destroy then
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
-- Best-effort: swallow a rejection (e.g. the load failed) so it is not uncaught.
|
|
86
|
+
dataStore:Save():Catch(function() end)
|
|
87
|
+
dataStore:Destroy()
|
|
88
|
+
end)
|
|
89
|
+
|
|
64
90
|
return dataStore
|
|
65
91
|
end)
|
|
66
92
|
assert(self._dataStorePromise, "Typechecking assertion")
|