@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.
Files changed (32) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +9 -9
  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/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")